From 8dd996053d3c010cd481ed5b94db22581179ce50 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Jul 2026 07:47:03 +0200 Subject: [PATCH] refactor(world): separate live lifetime from spatial buckets Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex --- docs/architecture/acdream-architecture.md | 21 +- docs/architecture/code-structure.md | 54 +- .../retail-divergence-register.md | 6 +- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 9 + .../Rendering/DollEntityBuilder.cs | 2 +- .../EquippedChildRenderController.cs | 112 +-- src/AcDream.App/Rendering/GameWindow.cs | 539 +++++++---- .../Rendering/Vfx/EntityScriptActivator.cs | 72 +- src/AcDream.App/Streaming/GpuWorldState.cs | 172 ++-- .../Streaming/LandblockEntityRehydrator.cs | 86 -- .../Streaming/StreamingController.cs | 2 +- src/AcDream.App/World/LiveEntityRuntime.cs | 732 +++++++++++++++ .../World/LiveEntityRuntimeViews.cs | 97 ++ .../ParentAttachmentState.cs | 2 +- .../World/LiveEntityRuntimeTests.cs | 876 ++++++++++++++++++ .../World/ParentAttachmentStateTests.cs | 1 - .../Vfx/EntityScriptActivatorTests.cs | 38 + .../Wb/PendingSpawnIntegrationTests.cs | 8 +- .../Streaming/GpuWorldStateActivatorTests.cs | 11 +- .../Streaming/GpuWorldStateTests.cs | 69 +- .../Streaming/GpuWorldStateTwoTierTests.cs | 28 +- .../LandblockEntityRehydratorTests.cs | 128 --- .../Streaming/StreamingControllerTests.cs | 14 +- 24 files changed, 2449 insertions(+), 631 deletions(-) delete mode 100644 src/AcDream.App/Streaming/LandblockEntityRehydrator.cs create mode 100644 src/AcDream.App/World/LiveEntityRuntime.cs create mode 100644 src/AcDream.App/World/LiveEntityRuntimeViews.cs rename src/AcDream.App/{Rendering => World}/ParentAttachmentState.cs (99%) create mode 100644 tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs delete mode 100644 tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c1bdc49e..cac849f0 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -255,14 +255,21 @@ Ownership by phase: ## GameEntity: The Unified Entity (target refactor) -Currently, entity state is scattered across: -- `WorldEntity` (position, rotation, mesh refs) -- `AnimatedEntity` (animation frame, setup, sequencer) -- `_entitiesByServerGuid` dict (server GUID lookup) -- `GpuWorldState._loaded[lb].Entities` (per-landblock lists) -- `_playerController` (player-specific movement) +`LiveEntityRuntime` is now the shipped bridge to this target. It owns one +`LiveEntityRecord` per accepted server-object incarnation, ServerGuid-to-local-id +translation, accepted snapshots/timestamp gates, animation and remote-motion +components, parent-event state, and exact logical teardown. `GpuWorldState` +owns spatial buckets only: register/rebucket/unregister are separate operations, +and landblock reloads reuse the same `WorldEntity` without replaying renderer or +script creation. Its canonical materialized view remains stable across pending +landblocks, while a separate visible-only view feeds radar, picking, status, and +targeting. Pickup/parent leave-world clears cell membership and pauses root +movement/animation without destroying retained owners. `GameWindow` retains +storage-free typed views while its large feature loops are extracted. -This should become ONE class: +The remaining aggregation is primarily `_playerController`'s player-specific +movement plus the separate `WorldEntity`/animation/physics component types. +Those should become ONE class: ```csharp public sealed class GameEntity diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 6f788832..e67edbfd 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -188,8 +188,9 @@ src/AcDream.App/ ├── Net/ │ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect ├── World/ -│ ├── InboundPhysicsStateController.cs # shipped: timestamps + accepted spawn snapshots -│ └── LiveEntityRuntime.cs # target: full lifecycle + ServerGuid↔entity.Id translation +│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots +│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation +│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations ├── Interaction/ │ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch ├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion @@ -226,16 +227,13 @@ The eventual `GameEntity` aggregation (target state described in Until then, the parallel-dicts problem is bounded inside one class instead of spread across `GameWindow`. -`InboundPhysicsStateController` is the first shipped slice of that boundary: -it owns the nine-channel retail timestamp gates and the latest accepted -immutable CreateObject snapshot. It deliberately owns no renderer adapter, -local entity ID, physics body, animation, effect, or spatial bucket; those -move together in the `LiveEntityRuntime` extraction. - -`ParentAttachmentState` is a focused interim pending queue for ParentEvent: -it keys unresolved relations by child and parent generation, distinguishes -atomic incarnation replacement from true deletion, and migrates into -`LiveEntityRuntime` with the general mixed packet queue. +`LiveEntityRuntime` is now that single boundary. It composes +`InboundPhysicsStateController` for the nine-channel retail timestamp gates and +latest accepted immutable CreateObject snapshot, owns the canonical local ID +and optional runtime components, and separates logical registration from +spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved +relations by child and parent generation. A future general mixed packet queue +still has to cover the non-Parent packet families tracked by divergence AD-32. --- @@ -280,22 +278,36 @@ constructed without a live socket (offline mode). **Verification:** Visual login + Holtburg traversal + door interaction identical to pre-extraction. -### Step 3 — `LiveEntityRuntime` (or `EntityRuntimeRegistry`) +### Step 3 — `LiveEntityRuntime` — SHIPPED 2026-07-14 -**Scope:** Centralize the parallel dictionaries — `_entitiesByServerGuid`, -the streaming entity lists, the player controller's player entity — -into one owner. Surface the ServerGuid↔entity.Id translation as a -single API (eliminating the trap from L.2g slice 1c). +**Shipped scope:** One `LiveEntityRecord` per server-object incarnation now owns +ServerGuid↔local-ID translation, accepted state, runtime components, parent +relations, logical resource activation, exact teardown, and spatial projection +state. `RegisterLiveEntity`, `RebucketLiveEntity`, and `UnregisterLiveEntity` +make the lifetime boundary explicit. Landblock unload/reload moves the same +`WorldEntity`; it cannot reconstruct from a stale CreateObject or replay setup +scripts. Equipped children use an attached projection and never enter the +top-level target/radar/status view. Canonical materialized lookup remains +available while a projection is pending; the separate visible view is the only +surface radar, picking, status, and targeting consume. Pickup/parent leave-world +clears cell membership and pauses root movement/animation without destroying the +retained owners. Top-level spawn publication is one-shot per incarnation, so +leave/re-entry restores presentation without duplicating plugin event replay. -**Behavior change:** None. +**Remaining target:** the player-specific controller is still a separate +aggregation, and AD-32 still tracks the future non-Parent mixed-packet queue. + +**Behavior change:** Spatial withdrawal and re-entry now preserve logical +identity and active resources instead of replaying create-time effects. **Risk:** Medium-high. Entity lookup is in every hot path. The change is structural (one owner instead of three) but the lookup semantics must be byte-identical. -**Test:** Entity-spawn / despawn / lookup tests in -`tests/AcDream.App.Tests/`. Existing visual verification at Holtburg -catches any drift in interaction. +**Test:** `LiveEntityRuntimeTests` cover duplicate CreateObject, generation +replacement, appearance mutation, loaded/pending rebucketing, attached +projection, pickup leave/re-entry, canonical-versus-visible lookup, resource +rollback, GUID reuse, and idempotent session teardown. **Verification:** Walk Holtburg, click NPC, open door, pick up item. All four M1 demo targets must still work. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 883864ba..5261a080 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -93,7 +93,7 @@ accepted-divergence entries (#96, #49, #50). | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | | AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | -| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. Older-incarnation packets drop in both clients. ParentEvent is no longer part of this divergence: `ParentAttachmentState` retains it by parent generation and replays it after both objects exist. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; Parent exception `src/AcDream.App/Rendering/ParentAttachmentState.cs` | acdream has no general per-object blob queue yet. Dropping is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. Parent's focused interim queue migrates into Step 2's `LiveEntityRuntime`, the owning seam for the complete mixed pending queue. | The first queued non-Parent change for a new incarnation can be absent until a later update if it arrived before CreateObject and was not already represented in that CreateObject | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; 0xF74C dispatch pc:357214-357239 | +| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. Effect packets share the missing queue: pre-materialization F754 currently plays immediately under a temporary server-GUID/camera anchor (teardown cleans that alias), while parsed F755 has no App consumer yet. Older-incarnation state packets drop in both clients. ParentEvent is no longer part of this divergence: runtime-owned `ParentAttachmentState` retains it by parent generation and replays it after both objects exist. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/World/LiveEntityRuntime.cs`; `GameWindow.OnPlayScriptReceived`; `WorldSession.PlayPhysicsScriptTypeReceived`; Parent exception `src/AcDream.App/World/ParentAttachmentState.cs` | acdream has no general per-object mixed packet queue yet. Dropping state is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. The temporary F754 owner is lifecycle-safe but not presentation-faithful. The shipped runtime now owns the focused Parent queue and is the seam where the complete mixed pending queue belongs. | The first queued non-Parent state can be absent until a later update; an early direct effect can appear at the camera instead of its owner; a typed effect can be absent entirely | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; F754/F755 dispatch and pending replay pc:357214-357239 | | AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) | | AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a | | AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | @@ -172,9 +172,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | | 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-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) | +| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Cell-scoped light presentation is removed by `LeaveWorldLiveEntityRuntimeComponents` and restored on world re-entry; logical teardown uses the same leave-world tail. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `LeaveWorldLiveEntityRuntimeComponents`; `TearDownLiveEntityRuntimeComponents`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) | | 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 | On a landblock (re)load, acdream re-projects retained server-object snapshots (`InboundPhysicsStateController.Snapshots` — our current `weenie_object_table` projection, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the accepted snapshots; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained table is pruned ONLY by an explicit server `DeleteObject` (0xF747), never by distance/time. (#138) | `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render | +| AP-69 | On a landblock unload/reload, acdream retains the accepted live record and the same `WorldEntity`. Full unload moves non-persistent live projections to that landblock's pending bucket; Near→Far demotion drops only dat-static layers and leaves live projections resident. Neither path replays logical registration or reconstructs from a stale CreateObject. ACE will NOT re-broadcast objects whose guid remains in its per-player `KnownObjects` set, matching retail's retained object-table model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained record is pruned ONLY by an explicit server `DeleteObject` (0xF747) or session teardown. (#138) | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Stable logical identity is retail-faithful and prevents duplicated meshes, scripts, emitters, or stale-spawn teleports while remaining independent of an ACE re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) can reappear at its last-known position, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | `CPhysicsObj::change_cell` 0x00513390; `CPhysicsObj::leave_world` 0x005155A0; ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS` | | AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | | 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 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a2ebc6b7..f4c29178 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -500,6 +500,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`. - **Wave 4.6 item giving implemented 2026-07-13; starter-dungeon live gate pending.** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. +- **Missile/portal VFX campaign Steps 0–2 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects; AP-69 is narrowed to the missing retail liveness cull and AD-32 to the remaining mixed pre-create queue. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 001d531b..64e0830e 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -471,6 +471,15 @@ include dungeons. `PlayerDescription.Options1` preserves retail's player secure-trade preference. See `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. +- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–2 + implemented and independently reviewed 2026-07-14)** — named-retail projectile and + physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` + and F754/F755 wire surfaces are parsed with retail timestamp gates, and + `LiveEntityRuntime` now separates logical lifetime from spatial rebucketing. + Landblock churn and equipment attachment preserve one stable local entity and + cannot replay create-time scripts. The remaining steps port DAT hook decoding, + per-owner effect scheduling, live animated attachment poses, projectile motion + and sweeps, and Hidden/UnHide portal presentation. - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action diff --git a/src/AcDream.App/Rendering/DollEntityBuilder.cs b/src/AcDream.App/Rendering/DollEntityBuilder.cs index 2b344120..3d64ccb3 100644 --- a/src/AcDream.App/Rendering/DollEntityBuilder.cs +++ b/src/AcDream.App/Rendering/DollEntityBuilder.cs @@ -36,7 +36,7 @@ public static class DollEntityBuilder /// /// Reserved render-local entity id for the doll. FIXED (the doll is a singleton) - /// and high enough never to collide with _liveEntityIdCounter ids. The + /// and high enough never to collide with LiveEntityRuntime's local ids. The /// renderer passes this in animatedEntityIds so the dispatcher treats the /// doll as animated — which BYPASSES the Tier-1 classification cache /// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index b5e7e69e..db8fb801 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -1,5 +1,5 @@ using System.Numerics; -using AcDream.App.Streaming; +using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Meshing; using AcDream.Core.Net; @@ -24,13 +24,10 @@ public sealed class EquippedChildRenderController : IDisposable private readonly DatCollection _dats; private readonly object _datLock; private readonly ClientObjectTable _objects; - private readonly GpuWorldState _worldState; - private readonly Func _resolveEntity; - private readonly Func _resolveSpawn; + private readonly LiveEntityRuntime _liveEntities; private readonly Func _acceptParent; - private readonly Func _nextEntityId; - private readonly ParentAttachmentState _relations = new(); + private ParentAttachmentState Relations => _liveEntities.ParentAttachments; private readonly Dictionary _attachedByChild = new(); public IEnumerable AttachedEntityIds @@ -46,20 +43,14 @@ public sealed class EquippedChildRenderController : IDisposable DatCollection dats, object datLock, ClientObjectTable objects, - GpuWorldState worldState, - Func resolveEntity, - Func resolveSpawn, - Func acceptParent, - Func nextEntityId) + LiveEntityRuntime liveEntities, + Func acceptParent) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); - _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); - _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); - _resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); - _nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId)); _objects.ObjectMoved += OnObjectMoved; _objects.MoveRolledBack += OnMoveRolledBack; @@ -79,7 +70,7 @@ public sealed class EquippedChildRenderController : IDisposable && spawn.ParentLocation is { } parentLocation && spawn.PlacementId is { } placementId) { - _relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( parentGuid, spawn.Guid, parentLocation, @@ -93,7 +84,7 @@ public sealed class EquippedChildRenderController : IDisposable // ParentEvent can precede the parent's CreateObject. Revisit every // child waiting specifically on the object that just arrived. - IReadOnlyList waiting = _relations.ChildrenWaitingForParent(spawn.Guid); + IReadOnlyList waiting = Relations.ChildrenWaitingForParent(spawn.Guid); for (int i = 0; i < waiting.Count; i++) { ResolveRelations(waiting[i]); @@ -112,7 +103,7 @@ public sealed class EquippedChildRenderController : IDisposable { lock (_datLock) { - IReadOnlyList waiting = _relations.ChildrenWaitingForParent(guid); + IReadOnlyList waiting = Relations.ChildrenWaitingForParent(guid); for (int i = 0; i < waiting.Count; i++) { ResolveRelations(waiting[i]); @@ -130,7 +121,7 @@ public sealed class EquippedChildRenderController : IDisposable { lock (_datLock) { - _relations.Enqueue(update); + Relations.Enqueue(update); ResolveRelations(update.ChildGuid); TryRealize(update.ChildGuid); } @@ -139,13 +130,11 @@ public sealed class EquippedChildRenderController : IDisposable public void OnGenerationReplaced(uint guid, ushort replacementGeneration) { TearDownObjectProjections(guid); - _relations.EndGeneration(guid, replacementGeneration); } public void OnGenerationDeleted(uint guid, ushort deletedGeneration) { TearDownObjectProjections(guid); - _relations.DeleteGeneration(guid, deletedGeneration); } private void OnObjectRemovalClassified(ClientObjectRemoval removal) @@ -160,7 +149,7 @@ public sealed class EquippedChildRenderController : IDisposable // same-generation world/parent update can still replay them. Logical // delete/replacement are driven directly by the accepted inbound // lifecycle and never depend on this table. - _relations.EndChildProjection(guid); + Relations.EndChildProjection(guid); } /// @@ -172,7 +161,7 @@ public sealed class EquippedChildRenderController : IDisposable public void OnChildBecameUnparented(uint childGuid) { Remove(childGuid); - _relations.EndChildProjection(childGuid); + Relations.EndChildProjection(childGuid); } /// Recompose every child after the parent's animation tick. @@ -180,8 +169,7 @@ public sealed class EquippedChildRenderController : IDisposable { foreach (AttachedChild child in _attachedByChild.Values) { - WorldEntity? parent = _resolveEntity(child.ParentGuid); - if (parent is null) + if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)) continue; if (!EquippedChildAttachment.TryCompose( @@ -199,22 +187,23 @@ public sealed class EquippedChildRenderController : IDisposable child.Entity.SetPosition(parent.Position); child.Entity.Rotation = parent.Rotation; child.Entity.ParentCellId = parent.ParentCellId; + if (parent.ParentCellId is { } parentCellId) + _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId); } } private void TryRealize(uint childGuid) { - if (!_relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending)) + if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending)) return; - WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid); - WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid); - WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid); - if (parentEntity is null || parentSpawn is null || childSpawn is null) + if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity) + || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn) + || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn)) return; - if (parentSpawn.Value.SetupTableId is not { } parentSetupId - || childSpawn.Value.SetupTableId is not { } childSetupId + if (parentSpawn.SetupTableId is not { } parentSetupId + || childSpawn.SetupTableId is not { } childSetupId || parentEntity.ParentCellId is not { } parentCellId) return; @@ -225,8 +214,8 @@ public sealed class EquippedChildRenderController : IDisposable var parentLocation = (ParentLocation)pending.ParentLocation; var placement = (Placement)pending.PlacementId; - IReadOnlyList template = BuildPartTemplate(childSetup, childSpawn.Value); - float scale = childSpawn.Value.ObjScale is { } objScale && objScale > 0f + IReadOnlyList template = BuildPartTemplate(childSetup, childSpawn); + float scale = childSpawn.ObjScale is { } objScale && objScale > 0f ? objScale : 1.0f; @@ -242,19 +231,32 @@ public sealed class EquippedChildRenderController : IDisposable return; Remove(childGuid); - var entity = new WorldEntity - { - Id = _nextEntityId(), - ServerGuid = childGuid, - SourceGfxObjOrSetupId = childSetupId, - Position = parentEntity.Position, - Rotation = parentEntity.Rotation, - MeshRefs = parts, - PaletteOverride = BuildPaletteOverride(childSpawn.Value), - ParentCellId = parentCellId, - }; - - _worldState.AppendLiveEntity(parentCellId, entity); + WorldEntity? entity = _liveEntities.MaterializeLiveEntity( + childGuid, + parentCellId, + localId => new WorldEntity + { + Id = localId, + ServerGuid = childGuid, + SourceGfxObjOrSetupId = childSetupId, + Position = parentEntity.Position, + Rotation = parentEntity.Rotation, + MeshRefs = parts, + PaletteOverride = BuildPaletteOverride(childSpawn), + ParentCellId = parentCellId, + }, + LiveEntityProjectionKind.Attached); + if (entity is null) + return; + entity.SetPosition(parentEntity.Position); + entity.Rotation = parentEntity.Rotation; + entity.ParentCellId = parentCellId; + entity.ApplyAppearance( + parts, + BuildPaletteOverride(childSpawn), + childSpawn.AnimPartChanges is { Count: > 0 } changes + ? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray() + : Array.Empty()); _attachedByChild[childGuid] = new AttachedChild( pending.ParentGuid, childGuid, @@ -268,15 +270,17 @@ public sealed class EquippedChildRenderController : IDisposable Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); - _relations.MarkProjected(childGuid); + Relations.MarkProjected(childGuid); } private void ResolveRelations(uint childGuid) { - _relations.Resolve( + Relations.Resolve( childGuid, - guid => _resolveSpawn(guid) is not null, - guid => _resolveSpawn(guid)?.InstanceSequence, + guid => _liveEntities.TryGetSnapshot(guid, out _), + guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn) + ? spawn.InstanceSequence + : null, _acceptParent); } @@ -370,7 +374,7 @@ public sealed class EquippedChildRenderController : IDisposable // A rejected unwield restores the equipped location without a fresh // wire ParentEvent; reinstall the last accepted relationship only for // this explicit rollback signal. - if (_relations.RestoreLastAccepted(item.ObjectId)) + if (Relations.RestoreLastAccepted(item.ObjectId)) { lock (_datLock) TryRealize(item.ObjectId); @@ -381,7 +385,7 @@ public sealed class EquippedChildRenderController : IDisposable { if (!_attachedByChild.Remove(childGuid)) return; - _worldState.RemoveEntityByServerGuid(childGuid); + _liveEntities.WithdrawLiveEntityProjection(childGuid); } private void TearDownObjectProjections(uint guid) @@ -401,7 +405,7 @@ public sealed class EquippedChildRenderController : IDisposable uint[] attached = _attachedByChild.Keys.ToArray(); for (int i = 0; i < attached.Length; i++) Remove(attached[i]); - _relations.Clear(); + Relations.Clear(); } public void Dispose() diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 40fa1908..c15741bc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,4 +1,5 @@ using AcDream.Core.Plugins; +using AcDream.App.World; using DatReaderWriter; using DatReaderWriter.Options; using Silk.NET.Input; @@ -245,7 +246,7 @@ public sealed class GameWindow : IDisposable /// Static decorations and entities with no motion table never /// appear in this map. /// - private readonly Dictionary _animatedEntities = new(); + private readonly LiveEntityAnimationRuntimeView _animatedEntities; // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically @@ -268,9 +269,10 @@ public sealed class GameWindow : IDisposable // #184 Slice 2a: internal (was private) so the extracted // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches // RemoteMotion's existing internal visibility. - internal sealed class AnimatedEntity + internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime { public required AcDream.Core.World.WorldEntity Entity; + AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity; public required DatReaderWriter.DBObjs.Setup Setup; public required DatReaderWriter.DBObjs.Animation Animation; public required int LowFrame; @@ -412,7 +414,7 @@ public sealed class GameWindow : IDisposable /// motion tables with HasVelocity=0). /// /// - private readonly Dictionary _remoteDeadReckon = new(); + private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon; /// /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, @@ -422,7 +424,7 @@ public sealed class GameWindow : IDisposable /// top of , dropped with the entity in /// . /// - private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new(); + private AcDream.App.World.LiveEntityRuntime? _liveEntities; /// /// Per-remote-entity physics + motion stack — verbatim application of @@ -440,9 +442,10 @@ public sealed class GameWindow : IDisposable /// remote gets the same treatment as the local player. /// /// - internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it + internal sealed class RemoteMotion : AcDream.App.World.ILiveEntityRemoteMotionRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it { public AcDream.Core.Physics.PhysicsBody Body; + AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; /// R5-V5: retail CPhysicsObj::movement_manager — the /// ONE per-entity owner of the interp + moveto pair (acclient.h @@ -983,7 +986,6 @@ public sealed class GameWindow : IDisposable // closes (stale-centered geometry built during the InWorld-but-not-yet- // located window, applied late once its build finished). private bool _liveCenterKnown; - private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id // K-fix1 (2026-04-26): cached at startup so per-frame branches are // single-flag reads instead of env-var lookups. True iff @@ -1016,10 +1018,19 @@ public sealed class GameWindow : IDisposable /// /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so /// UpdateMotion and UpdatePosition handlers can find the entity the - /// server is talking about. The sequential - /// keys the render list; this parallel dictionary keys by server guid. + /// server is talking about. This is the canonical materialized top-level + /// projection, including live objects parked in pending landblocks; + /// visibility must never suppress authoritative mutation. /// - private readonly Dictionary _entitiesByServerGuid = new(); + private static readonly IReadOnlyDictionary EmptyLiveEntityMap = + new Dictionary(); + private static readonly IReadOnlyDictionary EmptyLiveSpawnMap = + new Dictionary(); + private IReadOnlyDictionary _entitiesByServerGuid => + _liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap; + /// Visible-only view for radar, picking, status, and targets. + private IReadOnlyDictionary _visibleEntitiesByServerGuid => + _liveEntities?.WorldEntities ?? EmptyLiveEntityMap; /// /// Latest for each /// guid. Captured before the renderability gate so no-position inventory / @@ -1029,7 +1040,7 @@ public sealed class GameWindow : IDisposable /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// private IReadOnlyDictionary LastSpawns => - _inboundPhysics.Snapshots; + _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; // B.6/B.7 (2026-05-16): pending close-range action that will be fired // once the local auto-walk overlay reports arrival (body has finished // rotating to face the target). Only set for close-range Use/PickUp; @@ -1047,7 +1058,7 @@ public sealed class GameWindow : IDisposable // CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam, // giving the TargetManager voyeur round-trip its cross-entity delivery // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow - // (player); pruned in RemoveLiveEntityByServerGuid. + // (player); pruned only by logical LiveEntityRuntime teardown. private readonly Dictionary _physicsHosts = new(); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; @@ -1078,6 +1089,8 @@ public sealed class GameWindow : IDisposable _worldEvents = worldEvents; _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _uiRegistry = uiRegistry; + _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); + _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's @@ -1511,7 +1524,7 @@ public sealed class GameWindow : IDisposable // around the currently-selected entity. Delegates pull // live state from this GameWindow instance every frame: // - selected guid → shared SelectionState - // - entity resolver → position from _entitiesByServerGuid + + // - entity resolver → position from the visible world view + // itemType from ClientObjectTable (Objects) + last spawn // - camera → _cameraController.Active or (zero) when not // yet ready, in which case the panel bails on viewport==0. @@ -1519,7 +1532,7 @@ public sealed class GameWindow : IDisposable selectedGuidProvider: () => _selection.SelectedObjectId, entityResolver: guid => { - if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) + if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)) return null; uint rawItemType = (uint)LiveItemType(guid); uint pwdBits = 0; @@ -2098,7 +2111,7 @@ public sealed class GameWindow : IDisposable var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider( Objects, - _entitiesByServerGuid, + _visibleEntitiesByServerGuid, LastSpawns, playerGuid: () => _playerServerGuid, playerYawRadians: () => _playerController?.Yaw ?? 0f, @@ -2304,21 +2317,36 @@ public sealed class GameWindow : IDisposable // matching the LandblockHint stored at Populate time. _worldState = new AcDream.App.Streaming.GpuWorldState( wbSpawnAdapter, - wbEntitySpawnAdapter, onLandblockUnloaded: _classificationCache.InvalidateLandblock, entityScriptActivator: entityScriptActivator); + _liveEntities = new AcDream.App.World.LiveEntityRuntime( + _worldState, + new AcDream.App.World.DelegateLiveEntityResourceLifecycle( + entity => + { + wbEntitySpawnAdapter.OnCreate(entity); + entityScriptActivator.OnCreate(entity); + }, + entity => + { + try + { + entityScriptActivator.OnRemove(entity.Id); + } + finally + { + wbEntitySpawnAdapter.OnRemove(entity.ServerGuid); + } + }), + TearDownLiveEntityRuntimeComponents); + _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _dats!, _datLock, Objects, - _worldState, - guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null, - guid => LastSpawns.TryGetValue(guid, out var spawn) - ? spawn - : (AcDream.Core.Net.WorldSession.EntitySpawn?)null, - TryAcceptParentForRender, - () => _liveEntityIdCounter++); + _liveEntities, + TryAcceptParentForRender); _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, @@ -2537,7 +2565,16 @@ public sealed class GameWindow : IDisposable _equippedChildRenderer?.Clear(); Objects.Clear(); _selection.Reset(); - _inboundPhysics.Clear(); + try + { + _liveEntities?.Clear(); + } + finally + { + // F754 can precede CreateObject, so some temporary owners have no + // LiveEntityRecord for the normal logical teardown to visit. + _entityScriptActivator?.ClearLegacyPendingOwners(); + } } /// @@ -2933,32 +2970,49 @@ public sealed class GameWindow : IDisposable // with BuildLandblockForStreaming on the worker thread. lock (_datLock) { - AcDream.App.World.InboundCreateResult result = _inboundPhysics.AcceptCreate(spawn); + AcDream.App.World.LiveEntityRegistrationResult registration = + _liveEntities!.RegisterLiveEntity(spawn); + AcDream.App.World.InboundCreateResult result = registration.Inbound; if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) return; - PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); - - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration) + try { - _equippedChildRenderer?.OnGenerationReplaced( - spawn.Guid, - result.Snapshot.InstanceSequence); + PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); + + if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration) + { + _equippedChildRenderer?.OnGenerationReplaced( + spawn.Guid, + result.Snapshot.InstanceSequence); + } + + AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( + Objects, + spawn, + replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration); + + if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) + { + if (result.SameGenerationEvents is { } refresh) + RouteSameGenerationCreateObject(refresh); + return; + } + + OnLiveEntitySpawnedLocked(result.Snapshot); + } + catch (Exception applyFailure) when (registration.PriorGenerationCleanupFailure is not null) + { + throw new AggregateException( + $"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.", + registration.PriorGenerationCleanupFailure, + applyFailure); } - AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( - Objects, - spawn, - replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration); - - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) - { - if (result.SameGenerationEvents is { } refresh) - RouteSameGenerationCreateObject(refresh); - return; - } - - OnLiveEntitySpawnedLocked(result.Snapshot); + if (registration.PriorGenerationCleanupFailure is { } cleanupFailure) + throw new AggregateException( + $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", + cleanupFailure); } } @@ -2969,8 +3023,8 @@ public sealed class GameWindow : IDisposable /// AddLandblock / AddEntitiesToExistingLandblock. /// /// - /// The dungeon collapse (and Near→Far demote) drops a landblock's render - /// entities for FPS but keeps the parsed spawns in + /// A full landblock unload drops its dat-static render layer and parks live + /// projections pending, while keeping the parsed spawns in /// (our weenie_object_table for world objects). ACE never /// re-broadcasts objects it believes we still know — its per-player /// KnownObjects set is not cleared on a normal teleport (verified @@ -2981,55 +3035,53 @@ public sealed class GameWindow : IDisposable /// /// /// - /// Idempotent and cheap when nothing is missing: the selection skips guids - /// already present in (initial login, or objects - /// ACE did re-send), the player (owned by the persistent-entity rescue - /// path), and spawns with no world mesh. The replay's own - /// de-dup scrubs the state the - /// collapse orphaned — the entity lingers in - /// after RemoveLandblock even though its render entity is gone. + /// Idempotent and cheap when nothing is missing. A materialized record is + /// rebucketed with the same identity; a record whose Setup was unavailable + /// on first arrival gets one first-materialization attempt. Neither path + /// replays logical renderer or script registration. /// /// private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId) { - if (LastSpawns.Count == 0) return; + if (_liveEntities is null || _liveEntities.Count == 0) return; - // Server guids that already have a live render entity. The gate keys on - // GpuWorldState (the render projection), NOT _entitiesByServerGuid, which - // still holds collapse-orphaned entries whose render entity is gone. - var present = new HashSet(); - foreach (var e in _worldState.Entities) - if (e.ServerGuid != 0) present.Add(e.ServerGuid); + uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; + LiveEntityRecord[] records = _liveEntities.Records + .Where(record => record.ServerGuid != _playerServerGuid + && record.Snapshot.Position is { } position + && ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical + && record.Snapshot.SetupTableId is not null) + .ToArray(); + if (records.Length == 0) return; - // Snapshot retained spawns before render projection changes. - var retained = new List( - LastSpawns.Count); - foreach (var kv in LastSpawns) - { - var sp = kv.Value; - bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null; - uint spawnLb = sp.Position is { } p ? p.LandblockId : 0u; - retained.Add(new AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn( - kv.Key, spawnLb, hasWorldMesh)); - } - - var guids = AcDream.App.Streaming.LandblockEntityRehydrator.SelectGuidsToRehydrate( - loadedLandblockId, retained, present, _playerServerGuid); - if (guids.Count == 0) return; - - // Replay through the normal live-spawn build under the dat lock (it reads - // Setup/GfxObj/Surface dats). Each replay rebuilds the render entity into - // the now-loaded landblock via AppendLiveEntity's hot path. + int projected = 0; lock (_datLock) { - foreach (var guid in guids) - if (LastSpawns.TryGetValue(guid, out var spawn)) - OnLiveEntitySpawnedLocked(spawn); + foreach (LiveEntityRecord record in records) + { + // A materialized object keeps the same WorldEntity, renderer + // registration, animation owner, and scripts. Reloading a + // landblock changes only its spatial bucket. Hydration is used + // solely for a record that never acquired a projection. + if (record.WorldEntity is not null) + { + if (_liveEntities.RebucketLiveEntity( + record.ServerGuid, + record.Snapshot.Position!.Value.LandblockId)) + projected++; + } + else + { + OnLiveEntitySpawnedLocked(record.Snapshot); + if (record.WorldEntity is not null) + projected++; + } + } } - if (_options.DumpLiveSpawns) + if (_options.DumpLiveSpawns && projected > 0) Console.WriteLine( - $"live: re-hydrated {guids.Count} server object(s) into landblock 0x{loadedLandblockId:X8}"); + $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); } /// @@ -3051,19 +3103,10 @@ public sealed class GameWindow : IDisposable { _liveSpawnReceived++; - // De-dup: the server re-sends CreateObject for the same guid in - // several situations (visibility refresh, landblock crossing, - // appearance update). Without cleanup the OLD copy remains in - // GpuWorldState + WorldGameState + _animatedEntities, so the - // renderer draws both copies overlapped — producing the - // "NPC clothing changes when I turn the camera" bug because the - // depth test arbitrates between overlapping duplicates each frame. - // - // For a respawn, drop the previous rendering state here before we - // build the new one. `_entitiesByServerGuid` is the canonical map, - // its value is the live WorldEntity we need to dispose. - if (appearanceUpdate is null) - RemoveLiveEntityByServerGuid(spawn.Guid); + // LiveEntityRuntime has already classified this as a first + // materialization, a spatial re-entry of the same incarnation, or an + // appearance mutation. This method never de-duplicates by destroying a + // still-live projection and never reconstructs from stale spawn data. // Retail's weenie-object table retains CreateObject data for inventory // and parented children even when they have no world Position. Held @@ -3076,7 +3119,7 @@ public sealed class GameWindow : IDisposable // it above can synchronously advance the canonical child POSITION_TS // and turn this object into an attachment. Select the projection from // that post-callback snapshot, never from the stale method argument. - if (_inboundPhysics.TryGetSnapshot(spawn.Guid, out var canonicalSpawn)) + if (_liveEntities!.TryGetSnapshot(spawn.Guid, out var canonicalSpawn)) spawn = canonicalSpawn; // When requested, log every spawn that arrives so we can inventory what the server @@ -3634,23 +3677,61 @@ public sealed class GameWindow : IDisposable if (visualUpdate.Animation is { } animation) RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs); _classificationCache.InvalidateEntity(existing.Id); + if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) + && record.ProjectionKind is LiveEntityProjectionKind.Attached) + { + // The attachment controller composes child-local parts through + // the parent's current animated pose. Re-run that composition + // after replacing the child's visual description. + _equippedChildRenderer?.OnSpawn(spawn); + } return; } - var entity = new AcDream.Core.World.WorldEntity + bool createdProjection = false; + var entity = _liveEntities!.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + localId => + { + createdProjection = true; + var created = new AcDream.Core.World.WorldEntity + { + Id = localId, + ServerGuid = spawn.Guid, + SourceGfxObjOrSetupId = spawn.SetupTableId.Value, + Position = worldPos, + Rotation = rot, + MeshRefs = meshRefs, + PaletteOverride = paletteOverride, + PartOverrides = entityPartOverrides, + ParentCellId = spawn.Position.Value.LandblockId, + }; + if (liveBounds.TryGet(out var createdMin, out var createdMax)) + created.SetLocalBounds(createdMin, createdMax); + return created; + }); + if (entity is null) + return; + + if (!createdProjection) { - Id = _liveEntityIdCounter++, - ServerGuid = spawn.Guid, - SourceGfxObjOrSetupId = spawn.SetupTableId.Value, - Position = worldPos, - Rotation = rot, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = entityPartOverrides, - ParentCellId = spawn.Position!.Value.LandblockId, - }; - if (liveBounds.TryGet(out var liveBMin, out var liveBMax)) - entity.SetLocalBounds(liveBMin, liveBMax); + // A parented child already owns this incarnation's WorldEntity. + // Reuse it when a fresh Position returns the object to the world. + entity.SetPosition(worldPos); + entity.Rotation = rot; + entity.ParentCellId = spawn.Position.Value.LandblockId; + entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); + if (liveBounds.TryGet(out var retainedMin, out var retainedMax)) + entity.SetLocalBounds(retainedMin, retainedMax); + } + + // Retail CPhysicsObj::leave_world removes cell/shadow membership but + // retains PartArray and MovementManager. A Position after Pickup or + // parenting therefore re-enters with the same animation owner; do not + // replace its sequencer or replay initialization. + bool retainedAnimationRuntime = !createdProjection + && _animatedEntities.TryGetValue(entity.Id, out _); // A7 indoor lighting: server-spawned weenie fixtures (lanterns, // braziers, glowing items) carry their light in Setup.Lights exactly @@ -3658,8 +3739,8 @@ public sealed class GameWindow : IDisposable // ApplyLoadedTerrainLocked never sees CreateObject entities — so an // interior's lanterns cast no light and the room reads dark. Register // them here, mirroring that path (GameWindow.cs ~6742). Owned by - // entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears - // them down on despawn/respawn. Retail registers object-borne lights + // entity.Id, so leave-world and logical teardown both remove their + // cell-scoped presentation. Retail registers object-borne lights // regardless of static-vs-dynamic origin (insert_light 0x0054d1b0). // The light is placed at the spawn frame and does NOT follow a moving // light-bearer yet (register row AP-44) — fine for stationary fixtures, @@ -3699,17 +3780,18 @@ public sealed class GameWindow : IDisposable Position: entity.Position, Rotation: entity.Rotation); _worldGameState.Add(snapshot); - _worldEvents.FireEntitySpawned(snapshot); + if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid)) + _worldEvents.FireEntitySpawned(snapshot); // Phase A.1: register entity into GpuWorldState so the next frame picks - // it up. AppendLiveEntity is a no-op if the landblock isn't loaded yet - // (can happen if the server sends CreateObjects before we finish loading). - _worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity); + // it up. Materialization parks the projection when its landblock is + // not loaded yet, then AddLandblock merges the same identity. + // MaterializeLiveEntity above performed the spatial projection. _liveSpawnHydrated++; // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so // UpdateMotion / UpdatePosition events can reseat this entity by guid. - _entitiesByServerGuid[spawn.Guid] = entity; + // The GUID/local-id mapping is owned by LiveEntityRuntime. // The root now exists, so parent relations that arrived before this // object's render projection can compose their child meshes. @@ -3738,16 +3820,20 @@ public sealed class GameWindow : IDisposable // cycle (single-frame poses are static and don't need ticking). // Diagnostic: log why we did / didn't register so we can tell // which entities fall through the filter. - if (idleCycle is null) - _liveAnimRejectNoCycle++; - else if (idleCycle.Framerate == 0f) - _liveAnimRejectFramerate++; - else if (idleCycle.HighFrame <= idleCycle.LowFrame) - _liveAnimRejectSingleFrame++; - else if (idleCycle.Animation.PartFrames.Count <= 1) - _liveAnimRejectPartFrames++; + if (!retainedAnimationRuntime) + { + if (idleCycle is null) + _liveAnimRejectNoCycle++; + else if (idleCycle.Framerate == 0f) + _liveAnimRejectFramerate++; + else if (idleCycle.HighFrame <= idleCycle.LowFrame) + _liveAnimRejectSingleFrame++; + else if (idleCycle.Animation.PartFrames.Count <= 1) + _liveAnimRejectPartFrames++; + } - if (idleCycle is not null && idleCycle.Framerate != 0f + if (!retainedAnimationRuntime + && idleCycle is not null && idleCycle.Framerate != 0f && idleCycle.HighFrame > idleCycle.LowFrame && idleCycle.Animation.PartFrames.Count > 1) { @@ -3799,7 +3885,7 @@ public sealed class GameWindow : IDisposable _entitySoundTables.Set(entity.Id, soundTableId); } } - else if (_animLoader is not null) + else if (!retainedAnimationRuntime && _animLoader is not null) { // Phase B.4c / #187 — reactive motion-table rescue. An entity // whose REST pose is a static single frame fails the generic @@ -3908,17 +3994,23 @@ public sealed class GameWindow : IDisposable private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) { - if (!_inboundPhysics.TryDelete( - delete, - isLocalPlayer: delete.Guid == _playerServerGuid)) - return; - - _equippedChildRenderer?.OnGenerationDeleted( - delete.Guid, - delete.InstanceSequence); - AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); - - bool removed = RemoveLiveEntityByServerGuid(delete.Guid); + // A Delete can arrive before CreateObject, in which case no instance + // timestamp owner exists and the tracked F754 alias must be cleaned + // directly. For a known record, cleanup belongs after the runtime's + // generation gate: a stale Delete must not cancel the current + // incarnation's effect. + if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true) + _entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u); + bool removed = _liveEntities!.UnregisterLiveEntity( + delete, + isLocalPlayer: delete.Guid == _playerServerGuid, + beforeTeardown: () => + { + _equippedChildRenderer?.OnGenerationDeleted( + delete.Guid, + delete.InstanceSequence); + AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); + }); if (removed && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") @@ -3941,7 +4033,7 @@ public sealed class GameWindow : IDisposable /// private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update) { - if (!_inboundPhysics.TryApplyObjDesc(update, out var newSpawn)) + if (!_liveEntities!.TryApplyObjDesc(update, out var newSpawn)) { // Server can broadcast ObjDescEvent before we've seen a // CreateObject for this guid (race on landblock entry, or @@ -3971,7 +4063,7 @@ public sealed class GameWindow : IDisposable /// private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid) { - if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var entity)) + if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true) return null; _animatedEntities.TryGetValue(entity.Id, out var animation); @@ -4107,7 +4199,7 @@ public sealed class GameWindow : IDisposable /// Commit B 2026-04-29 — register a live (server-spawned) entity into /// the as a single collision body. /// One entry per entity (in contrast to static scenery's per-CylSphere - /// registration) so RemoveLiveEntityByServerGuid's single + /// registration) so the leave-world path's single /// Deregister(entity.Id) cleans it up without leaks. /// /// @@ -4272,9 +4364,9 @@ public sealed class GameWindow : IDisposable OnLiveAppearanceUpdated(refresh.Appearance); if (refresh.Parent is { } parent - && _inboundPhysics.TryApplyCreateParent(parent, out var acceptedParent)) + && _liveEntities!.TryApplyCreateParent(parent, out var acceptedParent)) { - RemoveLiveEntityByServerGuid(parent.ChildGuid); + WithdrawLiveEntityWorldProjection(parent.ChildGuid); _equippedChildRenderer?.OnSpawn(acceptedParent); } else if (refresh.Position is { } position) @@ -4299,11 +4391,11 @@ public sealed class GameWindow : IDisposable /// private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) { - if (!_inboundPhysics.TryApplyPickup(pickup, out _)) + if (!_liveEntities!.TryApplyPickup(pickup, out _)) return; _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid); - bool removed = RemoveLiveEntityByServerGuid(pickup.Guid); + bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid); if (removed && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") @@ -4325,8 +4417,8 @@ public sealed class GameWindow : IDisposable private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update) { - if (!_inboundPhysics.TryApplyParent(update, out _)) return false; - RemoveLiveEntityByServerGuid(update.ChildGuid); + if (!_liveEntities!.TryApplyParent(update, out _)) return false; + WithdrawLiveEntityWorldProjection(update.ChildGuid); return true; } @@ -4671,13 +4763,22 @@ public sealed class GameWindow : IDisposable host.PositionManager.StickTo(targetGuid, radius, height); } - private bool RemoveLiveEntityByServerGuid(uint serverGuid) + private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record) { - if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) - return false; + // AD-32 transitional F754 ownership: before Step 4's mixed pending + // packet FIFO, a direct script received before materialization is + // temporarily keyed by server GUID. It is still part of this logical + // incarnation and must be stopped even when no WorldEntity was ever + // materialized. Normal Setup/F754 owners use the local ID and are + // stopped by the resource lifecycle after this callback. + _entityScriptActivator?.OnRemoveLegacyOwner( + record.ServerGuid, + record.LocalEntityId ?? 0u); - _worldState.RemoveEntityByServerGuid(serverGuid); - _worldGameState.RemoveById(existingEntity.Id); + if (record.WorldEntity is not { } existingEntity) + return; + + uint serverGuid = record.ServerGuid; // 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 @@ -4713,15 +4814,11 @@ public sealed class GameWindow : IDisposable _physicsHosts.Remove(serverGuid); _animatedEntities.Remove(existingEntity.Id); _classificationCache.InvalidateEntity(existingEntity.Id); - _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); - if (serverGuid == _playerServerGuid) - _lastLocalPlayerShadow = null; // Dead-reckon state is keyed by SERVER guid (not local id) so we // clear using the same guid the next spawn/update would use. _remoteDeadReckon.Remove(serverGuid); _remoteLastMove.Remove(serverGuid); - _entitiesByServerGuid.Remove(serverGuid); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( @@ -4729,15 +4826,47 @@ public sealed class GameWindow : IDisposable AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); } - // A7 indoor lighting: release this entity's owned lights on EVERY - // removal, including the respawn-dedup path (former logDelete=false). - // A respawned weenie fixture would otherwise leak its old light set and - // double-register the new one. (Was gated on logDelete — harmless only - // while live weenies registered no lights, which is no longer true.) - _lightingSink?.UnregisterOwner(existingEntity.Id); _translucencyFades.ClearEntity(existingEntity.Id); // #188 + LeaveWorldLiveEntityRuntimeComponents(record); + } - return true; + /// + /// Retail CPhysicsObj::leave_world (0x005155A0): remove cell/shadow + /// presentation while retaining the PartArray, MovementManager, script + /// manager, animation owner, physics host, and effect owner. Pickup and + /// ParentEvent use this weaker transition; logical delete additionally + /// executes . + /// + private void LeaveWorldLiveEntityRuntimeComponents(LiveEntityRecord record) + { + if (record.WorldEntity is not { } entity) + return; + + _worldGameState.RemoveById(entity.Id); + _physicsEngine.ShadowObjects.Deregister(entity.Id); + if (record.ServerGuid == _playerServerGuid) + _lastLocalPlayerShadow = null; + + // Object-borne lights are cell-scoped presentation. The owning Setup + // and logical light capability remain on the live record; re-entry + // registers the light in the new cell without duplicating it. + _lightingSink?.UnregisterOwner(entity.Id); + } + + /// + /// Removes the world-facing projection of a still-live object. Pickup and + /// parenting advance POSITION_TS but do not end the object incarnation, so + /// the runtime identity and create-time mesh/script resources survive. + /// + private bool WithdrawLiveEntityWorldProjection(uint serverGuid) + { + if (_liveEntities is null + || !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) + || record.WorldEntity is null) + return false; + + LeaveWorldLiveEntityRuntimeComponents(record); + return _liveEntities.WithdrawLiveEntityProjection(serverGuid); } /// @@ -4884,7 +5013,7 @@ public sealed class GameWindow : IDisposable // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered // straggler re-applies an old gait or un-stops a stop. bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous; - if (!_inboundPhysics.TryApplyMotion( + if (!_liveEntities!.TryApplyMotion( update, retainPayload, out _, @@ -5374,7 +5503,7 @@ public sealed class GameWindow : IDisposable /// private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) { - if (!_inboundPhysics.TryApplyVector(update, out _)) return; + if (!_liveEntities!.TryApplyVector(update, out _)) return; if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; @@ -5444,7 +5573,7 @@ public sealed class GameWindow : IDisposable /// private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) { - if (!_inboundPhysics.TryApplyState(parsed, out _)) return; + if (!_liveEntities!.TryApplyState(parsed, out _)) return; if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; @@ -5576,7 +5705,7 @@ public sealed class GameWindow : IDisposable private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { - bool known = _inboundPhysics.TryApplyPosition( + bool known = _liveEntities!.TryApplyPosition( update, isLocalPlayer: update.Guid == _playerServerGuid, forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null @@ -5674,6 +5803,7 @@ public sealed class GameWindow : IDisposable entity.SetPosition(worldPos); entity.ParentCellId = p.LandblockId; entity.Rotation = rot; + _liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId); // Commit B 2026-04-29 — keep the shadow registry in sync with // server-authoritative position so the player's collision broadphase @@ -6402,7 +6532,7 @@ public sealed class GameWindow : IDisposable /// private void OnTeleportStarted(uint sequence) { - if (!_inboundPhysics.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence)) + if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence)) return; if (_playerController is not null) @@ -6417,19 +6547,15 @@ public sealed class GameWindow : IDisposable /// /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the /// (guid, scriptId) pair into - /// with the CAMERA's current world position as the anchor. For - /// scene-wide storm effects (lightning) the camera is the right - /// reference frame since the flash is meant to be "around the - /// player." For per-entity effects the runner's dedupe by - /// (scriptId, entityId) keeps multiple simultaneous plays - /// working on different guids. + /// Known server GUIDs are translated to the canonical local entity ID so + /// logical teardown stops both Setup and network-triggered scripts through + /// one owner key. Their current entity position is the anchor. Unknown + /// owners retain the legacy camera anchor until Step 4's pending FIFO can + /// replay them after materialization. /// /// - /// Improvements for follow-up: look up the guid's actual last- - /// known world position from _worldState so per-entity - /// spell casts and emote gestures anchor correctly. For Phase 6 - /// scope (lightning, which is Dereth-wide) the camera anchor is - /// sufficient. + /// F754 remains a direct PhysicsScript DID. It is never resolved through a + /// typed PhysicsScriptTable. /// /// private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message) @@ -6443,7 +6569,18 @@ public sealed class GameWindow : IDisposable camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); } - _scriptRunner.Play(message.ScriptDid, message.Guid, camWorldPos); + System.Numerics.Vector3 anchor = camWorldPos; + if (_liveEntities?.TryGetWorldEntity(message.Guid, out var entity) == true) + { + anchor = entity.Position; + _scriptRunner.Play(message.ScriptDid, entity.Id, anchor); + return; + } + + _entityScriptActivator?.PlayLegacyPending( + message.Guid, + message.ScriptDid, + anchor); } private void UpdateSkyPes( @@ -8084,7 +8221,7 @@ public sealed class GameWindow : IDisposable // landblocks are loaded into GpuWorldState before live-session // CreateObject events drain. The earlier order (live tick first, // streaming tick second) caused the initial CreateObject flood from - // login to land before any landblock was loaded; AppendLiveEntity + // login to land before any landblock was loaded; live projection placement // is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were // silently dropped on the first frame and never rendered. // @@ -8235,7 +8372,7 @@ public sealed class GameWindow : IDisposable { uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF); foreach (var entity in rescued) - _worldState.AppendLiveEntity(centerLb, entity); + _liveEntities?.RebucketLiveEntity(entity.ServerGuid, centerLb); } } @@ -8483,14 +8620,14 @@ public sealed class GameWindow : IDisposable // teleport's rescue/re-inject (GpuWorldState.DrainRescued) already // placed at the destination center — back into the now-UNLOADED // SOURCE landblock's pending bucket, where nothing recovers it - // (RelocateEntity only scans _loaded). Net: the avatar vanishes + // (the old relocate path only scanned _loaded). Net: the avatar vanishes // after teleporting out. The teleport machinery owns the avatar's // landblock during transit; per-frame relocation resumes at // FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561 // APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING -> // DRAWSET ABSENT, never recovered. if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace) - _worldState.RelocateEntity(pe, currentLb); + _liveEntities?.RebucketLiveEntity(pe.ServerGuid, currentLb); } // Update chase camera(s). The CameraController exposes whichever @@ -10072,6 +10209,15 @@ public sealed class GameWindow : IDisposable // exactly matching the old scan's miss case. uint serverGuid = ae.Entity.ServerGuid; + // Retail CPhysicsObj::UpdateObjectInternal skips root movement, + // MovementManager use, and PartArray animation while cell == 0. + // Pickup/parent leave-world preserves the same owners (including + // PhysicsScript/particles, ticked elsewhere) but must not keep the + // withdrawn body animating in its former cell. + if (serverGuid != 0 + && _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false) + continue; + // ── Dead-reckoning: smooth position between UpdatePosition bursts. // The server broadcasts UpdatePosition at ~5-10Hz for distant // entities; without integration, remote chars jitter-hop between @@ -12171,7 +12317,7 @@ public sealed class GameWindow : IDisposable || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) || _selection.SelectedObjectId is not uint selected || !IsLiveHostileMonsterTarget(selected) - || !_entitiesByServerGuid.TryGetValue(selected, out var target)) + || !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target)) return null; return target.Position @@ -12221,7 +12367,7 @@ public sealed class GameWindow : IDisposable mouseX: mouseX, mouseY: mouseY, view: camera.View, projection: camera.Projection, viewport: viewport, - candidates: _entitiesByServerGuid.Values, + candidates: _visibleEntitiesByServerGuid.Values, skipServerGuid: includeSelf ? 0u : _playerServerGuid, // Resolver: Setup's SelectionSphere is the ONLY input. If the // entity's Setup didn't bake a SelectionSphere, return null — @@ -12566,7 +12712,7 @@ public sealed class GameWindow : IDisposable private bool IsCloseRangeTarget(uint targetGuid) { if (_playerController is null) return false; - if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity)) + if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) return false; // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic. @@ -12592,7 +12738,7 @@ public sealed class GameWindow : IDisposable private void InstallSpeculativeTurnToTarget(uint targetGuid) { if (_playerController is not { } pc || pc.MoveTo is null) return; - if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity)) + if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) return; // Per-type use radius — same heuristic as the picker's @@ -12683,7 +12829,7 @@ public sealed class GameWindow : IDisposable uint? bestGuid = null; float bestDistanceSq = float.PositiveInfinity; - foreach (var (guid, entity) in _entitiesByServerGuid) + foreach (var (guid, entity) in _visibleEntitiesByServerGuid) { if (!IsLiveHostileMonsterTarget(guid)) continue; @@ -12723,10 +12869,10 @@ public sealed class GameWindow : IDisposable { if (guid == _playerServerGuid) return false; - if (!_entitiesByServerGuid.ContainsKey(guid)) + if (!_visibleEntitiesByServerGuid.ContainsKey(guid)) return false; - if (_entitiesByServerGuid.TryGetValue(guid, out var entity) + if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity) && _animatedEntities.TryGetValue(entity.Id, out var animated) && animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead) return false; @@ -12791,7 +12937,7 @@ public sealed class GameWindow : IDisposable worldCenter = default; worldRadius = 0f; - if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false; + if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)) return false; if (!LastSpawns.TryGetValue(guid, out var spawn)) return false; if (spawn.SetupTableId is not uint setupId) return false; if (_dats is null) return false; @@ -13829,7 +13975,14 @@ public sealed class GameWindow : IDisposable _equippedChildRenderer?.Dispose(); _liveSessionController?.Dispose(); _liveSession = null; - _inboundPhysics.Clear(); + try + { + _liveEntities?.Clear(); + } + finally + { + _entityScriptActivator?.ClearLegacyPendingOwners(); + } _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index a8b0d2b7..7a45942c 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -27,20 +27,17 @@ public sealed record ScriptActivationInfo( /// Stops the scripts and live emitters when the entity despawns. /// /// -/// Handles both server-spawned entities (ServerGuid != 0, keyed by -/// ServerGuid) and dat-hydrated entities (ServerGuid == 0, keyed by -/// entity.Id). The C.1.5a guard that early-returned for +/// Handles both server-spawned and dat-hydrated entities, always keyed by +/// canonical local entity.Id. The C.1.5a guard that early-returned for /// ServerGuid == 0 was relaxed in C.1.5b so EnvCell static objects /// (which have no server guid because they come from the dat file, not /// the network) also fire their DefaultScript. /// /// /// -/// Wires alongside EntitySpawnAdapter in GpuWorldState: the -/// adapter handles meshes + animation state, the activator handles scripts -/// + particles. Both are render-thread-only. The activator is invoked from -/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock, -/// AddEntitiesToExistingLandblock, plus the matching remove paths). +/// For live objects this is invoked by LiveEntityRuntime logical +/// registration/teardown. GpuWorldState invokes it only for dat-static +/// landblock load, promotion, demotion, and unload paths. /// /// /// @@ -55,6 +52,7 @@ public sealed class EntityScriptActivator private readonly PhysicsScriptRunner _scriptRunner; private readonly ParticleHookSink _particleSink; private readonly Func _resolver; + private readonly HashSet _legacyPendingOwners = new(); /// Already-shipped runner from C.1. Owns the /// (scriptId, entityId) instance table and schedules hooks at their @@ -84,17 +82,14 @@ public sealed class EntityScriptActivator /// /// Resolve the entity's Setup.DefaultScript and fire it through - /// the script runner. Keys by entity.ServerGuid when non-zero, - /// otherwise by entity.Id (the latter handles dat-hydrated - /// EnvCell statics + exterior stabs whose entity.Id lives in - /// the 0x40xxxxxx range — collision-free with server guids). + /// the script runner, keyed by canonical local entity.Id. /// No-op if the entity has no DefaultScript (resolver returns null /// or zero-script). /// public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); - uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + uint key = entity.Id; if (key == 0) return; // malformed entity var info = _resolver(entity); @@ -119,10 +114,8 @@ public sealed class EntityScriptActivator /// /// Stop every script instance the runner is tracking for this key, and - /// kill every live emitter the sink has attributed to it. Caller picks - /// the key (the matching ServerGuid for live entities, or - /// entity.Id for dat-hydrated entities — mirror whatever was - /// used at ). Idempotent for unknown keys. + /// kill every live emitter the sink has attributed to the canonical + /// local entity id. Idempotent for unknown keys. /// public void OnRemove(uint key) { @@ -130,4 +123,49 @@ public sealed class EntityScriptActivator _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); } + + /// + /// Starts the temporary server-GUID owner used when F754 precedes the + /// target's CreateObject/materialization. Step 4 replaces this with the + /// retail mixed pending-packet FIFO. Tracking is required because no + /// LiveEntityRecord may exist yet for delete/session teardown. + /// + public bool PlayLegacyPending( + uint serverGuid, + uint scriptDid, + Vector3 anchorWorldPosition) + { + if (serverGuid == 0) + return false; + bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition); + if (played) + _legacyPendingOwners.Add(serverGuid); + return played; + } + + /// + /// Cleans the temporary server-GUID owner used when F754 arrives before a + /// live projection has a canonical local ID. Step 4 replaces this alias + /// with the retail mixed pending-packet FIFO; until then teardown must stop + /// the alias as well as the normal local owner so an early effect cannot + /// outlive its object incarnation. + /// + public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId) + { + if (serverGuid == 0 || serverGuid == canonicalLocalId) + return; + if (!_legacyPendingOwners.Remove(serverGuid)) + return; + OnRemove(serverGuid); + } + + /// Stops every pre-Create F754 alias at session teardown. + public void ClearLegacyPendingOwners() + { + foreach (uint serverGuid in _legacyPendingOwners) + OnRemove(serverGuid); + _legacyPendingOwners.Clear(); + } + + public int LegacyPendingOwnerCount => _legacyPendingOwners.Count; } diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 2e2c142d..2a977516 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -16,7 +16,8 @@ namespace AcDream.App.Streaming; /// /// Replaces the pre-streaming flat _entities list. This class is the /// single point of truth for "what's in the world right now" and the only -/// thing that mutates it. +/// thing that mutates spatial buckets. Logical live-object identity and +/// create-time resources are owned by . /// /// /// @@ -25,13 +26,13 @@ namespace AcDream.App.Streaming; /// X is loaded into (frequently true on the first /// frame after login, where the entire post-login spawn flood drains /// before the streaming controller has finished loading the visible -/// window). To survive this race, stores +/// window). To survive this race, stores /// orphaned spawns in a per-landblock pending bucket. When /// later loads the landblock, the matching /// pending entries are merged into the loaded record before the flat -/// view rebuild. drops pending entries for -/// the same landblock — if the landblock just left the visible window, -/// any spawns that came with it are no longer relevant. +/// view rebuild. retains non-persistent live +/// entries in that pending bucket so a later reload restores the same identity; +/// only dat-static entries are discarded with streaming residence. /// /// /// @@ -41,7 +42,6 @@ namespace AcDream.App.Streaming; public sealed class GpuWorldState { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; - private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; private readonly EntityScriptActivator? _entityScriptActivator; /// @@ -58,12 +58,10 @@ public sealed class GpuWorldState public GpuWorldState( LandblockSpawnAdapter? wbSpawnAdapter = null, - EntitySpawnAdapter? wbEntitySpawnAdapter = null, System.Action? onLandblockUnloaded = null, EntityScriptActivator? entityScriptActivator = null) { _wbSpawnAdapter = wbSpawnAdapter; - _wbEntitySpawnAdapter = wbEntitySpawnAdapter; _onLandblockUnloaded = onLandblockUnloaded; _entityScriptActivator = entityScriptActivator; } @@ -89,11 +87,15 @@ public sealed class GpuWorldState // rebuilt on each add/remove. The renderer holds a reference to this // list, so rebuilding it replaces the reference atomically. private IReadOnlyList _flatEntities = System.Array.Empty(); + private readonly HashSet _visibleLiveGuids = new(); public IReadOnlyList Entities => _flatEntities; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; + public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); + public bool IsLiveEntityVisible(uint serverGuid) => + serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid); /// /// Try to grab the loaded record for a landblock — useful for callers @@ -219,8 +221,9 @@ public sealed class GpuWorldState _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). - // Live entities (ServerGuid!=0) already had OnCreate fired at - // AppendLiveEntity; the filter avoids double-firing pending-bucket merges. + // LiveEntityRuntime owns activation for live objects. This static-only + // filter avoids replaying their defaults when a pending projection is + // drained into a newly loaded landblock. if (_entityScriptActivator is not null) { var loadedEntities = _loaded[landblock.LandblockId].Entities; @@ -251,7 +254,7 @@ public sealed class GpuWorldState /// the entity stays in the spawn landblock and gets frustum-culled when /// the player walks away. /// - public void RelocateEntity(WorldEntity entity, uint newCanonicalLb) + public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb) { if (entity.ServerGuid == 0) return; @@ -275,19 +278,19 @@ public sealed class GpuWorldState // the player stranded, hidden, even after its landblock finished loading // (the AddLandblock pending-drain had already run empty before the churn // re-parked the player, and the player is excluded from the server-object - // re-hydrate — so RelocateEntity was the ONLY path that could recover it, + // re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it, // and it couldn't reach a pending entity). Re-appending routes the entity // to _loaded (drawn) when its landblock is loaded, or back to pending to // await AddLandblock otherwise. RemoveEntityFromAllBuckets(entity); - AppendLiveEntity(canonical, entity); + PlaceLiveEntityProjection(canonical, entity); } /// /// Remove (by reference) from whichever /// or bucket it /// currently occupies. At most one bucket holds a given entity, so this - /// stops after the first hit. Called by before + /// stops after the first hit. Called by before /// re-appending, so a stranded pending entity can be promoted. /// private void RemoveEntityFromAllBuckets(WorldEntity entity) @@ -304,6 +307,7 @@ public sealed class GpuWorldState if (j != i) newList.Add(entities[j]); _loaded[kvp.Key] = new LoadedLandblock( kvp.Value.LandblockId, kvp.Value.Heightmap, newList); + RebuildFlatView(); return; } } @@ -318,28 +322,44 @@ public sealed class GpuWorldState public void RemoveLandblock(uint landblockId) { + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockUnloaded(landblockId); + _wbSpawnAdapter.OnLandblockUnloaded(canonical); + + // A logical live object survives streaming residence. Non-persistent + // projections move to the pending bucket for this landblock and merge + // back as the same WorldEntity when it reloads. Persistent projections + // (the local player) are rescued because their current bucket may be a + // different landblock by the time the caller reinjects them. + var retainedLive = new List(); + void RetainOrRescue(WorldEntity entity, string source) + { + if (entity.ServerGuid == 0) + return; + if (_persistentGuids.Contains(entity.ServerGuid)) + { + _persistentRescued.Add(entity); + EntityVanishProbe.Log( + $"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from={source} lb=0x{canonical:X8}"); + return; + } + if (!retainedLive.Any(existing => ReferenceEquals(existing, entity))) + retainedLive.Add(entity); + } // Rescue persistent entities before removal. These get appended // to the _persistentRescued list; the caller is responsible for - // re-injecting them (via AppendLiveEntity) into whatever landblock + // re-injecting them through LiveEntityRuntime rebucketing into whatever landblock // the player is currently on. - if (_loaded.TryGetValue(landblockId, out var lb)) + if (_loaded.TryGetValue(canonical, out var lb)) { foreach (var entity in lb.Entities) - { - if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid)) - { - _persistentRescued.Add(entity); - EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=loaded lb=0x{landblockId:X8}"); - } - } + RetainOrRescue(entity, "loaded"); // C.1.5b: stop DefaultScript for each dat-hydrated entity in // the landblock. Server-spawned entities are either being // rescued (script continues at the new LB) or were OnRemove'd - // via RemoveEntityByServerGuid earlier; leave them alone here. + // by LiveEntityRuntime logical teardown; leave them alone here. if (_entityScriptActivator is not null) { foreach (var entity in lb.Entities) @@ -352,7 +372,7 @@ public sealed class GpuWorldState // #138 (secondary): rescue persistent entities sitting in the PENDING // bucket too, not just the loaded list. The player is re-injected via - // AppendLiveEntity into its current landblock every frame + // LiveEntityRuntime rebucketing into its current landblock every frame // (GameWindow's DrainRescued loop); right after a teleport that // landblock often hasn't streamed in yet, so the player lands in // _pendingByLandblock. If that same landblock is then unloaded (a @@ -361,22 +381,18 @@ public sealed class GpuWorldState // "persistent ⇒ survives unload" contract and making the avatar // vanish after a couple round-trips. Rescue them so DrainRescued // re-parks them at the next valid landblock. - if (_pendingByLandblock.TryGetValue(landblockId, out var pendingForLb)) + if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb)) { foreach (var entity in pendingForLb) - { - if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid)) - { - _persistentRescued.Add(entity); - EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=pending lb=0x{landblockId:X8}"); - } - } + RetainOrRescue(entity, "pending"); } - _pendingByLandblock.Remove(landblockId); - _aabbs.Remove(landblockId); + _pendingByLandblock.Remove(canonical); + if (retainedLive.Count > 0) + _pendingByLandblock[canonical] = retainedLive; + _aabbs.Remove(canonical); - if (_loaded.Remove(landblockId)) + if (_loaded.Remove(canonical)) RebuildFlatView(); } @@ -384,7 +400,7 @@ public sealed class GpuWorldState /// /// Drain entities rescued from unloaded landblocks. The caller should - /// re-inject each via with its current position. + /// re-inject each through LiveEntityRuntime.RebucketLiveEntity with its current position. /// public List DrainRescued() { @@ -397,24 +413,16 @@ public sealed class GpuWorldState /// /// Remove every entity with the given from /// all loaded landblocks AND all pending buckets, then rebuild the flat - /// view. Used by the live CreateObject handler to de-duplicate - /// when the server re-sends a spawn (visibility refresh, landblock - /// crossing, etc.). Without this, multiple copies of the same NPC - /// accumulate in the renderer, each with its own PaletteOverride - /// and MeshRefs — producing "NPC clothing flickers as I turn the - /// camera" because the depth test picks different duplicates frame-to-frame. + /// view. Called by LiveEntityRuntime before rebucketing, temporary + /// world withdrawal, or logical teardown. It owns no renderer/script + /// lifecycle and is safe for a still-live incarnation. /// /// Safe to call with a server guid that's not currently present — no-op. /// - public void RemoveEntityByServerGuid(uint serverGuid) + public void RemoveLiveEntityProjection(uint serverGuid) { if (serverGuid == 0) return; - // Phase N.4 Task 17: release per-instance state for server-spawned - // entities. No-op for atlas-tier entities (never registered). - _wbEntitySpawnAdapter?.OnRemove(serverGuid); - _entityScriptActivator?.OnRemove(serverGuid); - bool rebuiltLoaded = false; // Scan loaded landblocks. ToArray() so we can mutate _loaded inside. @@ -435,8 +443,7 @@ public sealed class GpuWorldState rebuiltLoaded = true; } - // Scrub pending buckets too — a duplicate CreateObject may arrive - // while the landblock is still loading. + // Scrub pending buckets too so rebucketing cannot leave a second slot. foreach (var kvp in _pendingByLandblock) kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid); @@ -444,9 +451,8 @@ public sealed class GpuWorldState } /// - /// Append an entity to a specific landblock's slot. Used by the live - /// CreateObject path where the server spawns entities at a server-side - /// position whose landblock may or may not be loaded yet. + /// Place an already-registered live entity in a landblock slot whose + /// terrain may or may not be loaded yet. /// /// /// The server's landblockId is in cell-resolved form @@ -468,14 +474,10 @@ public sealed class GpuWorldState /// /// /// - public void AppendLiveEntity(uint landblockId, WorldEntity entity) + public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) { - // Phase N.4 Task 17: route server-spawned entities through the - // per-instance adapter. Atlas-tier entities (ServerGuid == 0) are - // skipped by OnCreate — it returns null immediately for those. - _wbEntitySpawnAdapter?.OnCreate(entity); - _entityScriptActivator?.OnCreate(entity); - + // Spatial placement only. LiveEntityRuntime has already registered + // this incarnation's renderer and script resources exactly once. uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; bool probePersistent = EntityVanishProbe.Enabled && entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid); @@ -516,20 +518,16 @@ public sealed class GpuWorldState /// Per Phase A.5 spec §4.4. /// /// - /// Persistent-entity rescue is intentionally omitted (unlike - /// ): demote-tier entities are atlas-tier - /// only (procedural scenery, dat-static stabs/buildings) — they never - /// have ServerGuid != 0 and so can never be in . - /// The local player and other live server-spawned entities live in their - /// landblock via RelocateEntity per frame and are not affected - /// by Near→Far demotion of dat-static landblock layers. + /// Only dat-static entity layers demote. Live server projections retain + /// their exact WorldEntity and bucket while terrain remains resident; no + /// logical or spatial lifetime callback is replayed. /// /// public void RemoveEntitiesFromLandblock(uint landblockId) { - // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. // Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this - // protects against future callers that mirror AppendLiveEntity's + // protects against future callers that mirror live projection placement's // cell-resolved-id pattern. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) return; @@ -555,11 +553,19 @@ public sealed class GpuWorldState } } + WorldEntity[] retainedLive = lb.Entities + .Where(entity => entity.ServerGuid != 0) + .ToArray(); _loaded[canonical] = new LoadedLandblock( lb.LandblockId, lb.Heightmap, - System.Array.Empty()); - _pendingByLandblock.Remove(canonical); + retainedLive); + if (_pendingByLandblock.TryGetValue(canonical, out var pending)) + { + pending.RemoveAll(entity => entity.ServerGuid == 0); + if (pending.Count == 0) + _pendingByLandblock.Remove(canonical); + } RebuildFlatView(); } @@ -578,11 +584,11 @@ public sealed class GpuWorldState /// public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities) { - // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) { - // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs. + // Park as pending — same pattern as live projections for not-yet-loaded LBs. if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { bucket = new List(); @@ -614,6 +620,22 @@ public sealed class GpuWorldState private void RebuildFlatView() { _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); + var nowVisible = new HashSet( + _flatEntities + .Where(entity => entity.ServerGuid != 0) + .Select(entity => entity.ServerGuid)); + foreach (uint guid in _visibleLiveGuids) + { + if (!nowVisible.Contains(guid)) + LiveProjectionVisibilityChanged?.Invoke(guid, false); + } + foreach (uint guid in nowVisible) + { + if (!_visibleLiveGuids.Contains(guid)) + LiveProjectionVisibilityChanged?.Invoke(guid, true); + } + _visibleLiveGuids.Clear(); + _visibleLiveGuids.UnionWith(nowVisible); if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); } diff --git a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs deleted file mode 100644 index 66aaff59..00000000 --- a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Generic; - -namespace AcDream.App.Streaming; - -/// -/// Decides which retained server-object spawns to re-hydrate (rebuild render -/// entities for) when a landblock (re)loads. The pure selection half of the -/// #138 fix; GameWindow owns the replay half (it alone can rebuild a -/// mesh from a CreateObject). -/// -/// -/// Why this exists (retail/ACE model). A real AC client keeps its -/// weenie_object_table across a teleport and re-projects its rendered -/// world from that table; the server does NOT re-broadcast objects it believes -/// the client already knows (ACE's per-player KnownObjects set is never -/// cleared on a normal teleport — ACE relies on the client retaining the -/// table, cross-checked against references/holtburger + -/// references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs). -/// -/// -/// -/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's -/// RENDER entities for FPS but retains the accepted immutable spawns in -/// InboundPhysicsStateController.Snapshots (pruned by an explicit server -/// DeleteObject or session teardown). On reload, ACE will -/// not re-send the objects it still thinks we have, so the render side stays -/// empty — the #138 symptom. This selects the retained spawns whose render -/// entity is currently absent so GameWindow can replay them, making -/// re-delivery independent of the server. -/// -/// -public static class LandblockEntityRehydrator -{ - /// - /// A retained spawn reduced to just the fields the selection needs. - /// is true only when the spawn carries both - /// a world position AND a Setup id — i.e. it would actually build a visible - /// render entity (inventory items and setup-less spawns produce none and - /// are skipped, matching OnLiveEntitySpawnedLocked's own guard). - /// - public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh); - - /// - /// Canonical landblock id (low 16 bits forced to 0xFFFF) — the same - /// keying uses, so a - /// cell-resolved spawn id (0xAAAA00CC) and a streamed landblock id - /// (0xAAAAFFFF) compare equal when they name the same landblock. - /// - public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu; - - /// - /// Select the guids whose retained spawn should be replayed for the - /// just-loaded . - /// - /// The landblock that just (re)loaded. - /// Snapshot of the retained world-object spawns. - /// - /// Server guids that already have a live render entity in - /// . A guid here was either never dropped or was - /// already re-delivered by the server, so replaying it would be redundant. - /// - /// - /// The local player's guid — never re-hydrated here; it is owned by the - /// persistent-entity rescue path ( - /// + DrainRescued), which preserves the player's live pose/position - /// rather than resetting it to the spawn snapshot. - /// - public static List SelectGuidsToRehydrate( - uint loadedLandblockId, - IReadOnlyCollection retainedSpawns, - IReadOnlySet presentServerGuids, - uint playerServerGuid) - { - uint loadedCanonical = Canonicalize(loadedLandblockId); - var result = new List(); - foreach (var s in retainedSpawns) - { - if (!s.HasWorldMesh) continue; // no visible entity to rebuild - if (s.Guid == playerServerGuid) continue; // player: persistent-rescue path owns it - if (Canonicalize(s.SpawnLandblockId) != loadedCanonical) continue; // different landblock - if (presentServerGuids.Contains(s.Guid)) continue; // already rendered - result.Add(s.Guid); - } - return result; - } -} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 99da350e..d39b3d9f 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -454,7 +454,7 @@ public sealed class StreamingController case LandblockStreamResult.Loaded loaded: _applyTerrain(loaded.Build, loaded.MeshData); _state.AddLandblock(loaded.Landblock); - // #138: after the landblock is in _loaded (so AppendLiveEntity + // #138: after the landblock is in _loaded (so live projection // hot-paths), restore any retained server objects ACE won't // re-send. Fired AFTER AddLandblock, never before. _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs new file mode 100644 index 00000000..c8134dec --- /dev/null +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -0,0 +1,732 @@ +using AcDream.App.Streaming; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.World; + +/// +/// Animation state owned by a live object. The concrete App animation runtime +/// implements this seam so identity storage does not depend on a render backend. +/// +public interface ILiveEntityAnimationRuntime +{ + WorldEntity Entity { get; } +} + +/// Remote motion state owned by a live object. +public interface ILiveEntityRemoteMotionRuntime +{ + PhysicsBody Body { get; } +} + +/// Marker for the projectile runtime added by the projectile phase. +public interface ILiveEntityProjectileRuntime { } + +/// Marker for the DAT-driven effect profile added by the effect phase. +public interface ILiveEntityEffectProfile { } + +public enum LiveEntityProjectionKind +{ + World, + Attached, +} + +/// +/// Logical-resource seam coordinated by . +/// Spatial bucketing is deliberately absent: registering or removing meshes, +/// scripts, and effects is a logical-lifetime operation, not a landblock move. +/// +public interface ILiveEntityResourceLifecycle +{ + void Register(WorldEntity entity); + void Unregister(WorldEntity entity); +} + +/// Delegate adapter used by the App composition root. +public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle +{ + private readonly Action _register; + private readonly Action _unregister; + + public DelegateLiveEntityResourceLifecycle( + Action register, + Action unregister) + { + _register = register ?? throw new ArgumentNullException(nameof(register)); + _unregister = unregister ?? throw new ArgumentNullException(nameof(unregister)); + } + + public void Register(WorldEntity entity) => _register(entity); + public void Unregister(WorldEntity entity) => _unregister(entity); +} + +/// +/// The one logical record for a server object incarnation. The record survives +/// loaded/pending landblock movement and temporary loss of its render bucket. +/// Only an accepted DeleteObject, session reset, or newer INSTANCE_TS ends it. +/// +public sealed class LiveEntityRecord +{ + internal LiveEntityRecord(WorldSession.EntitySpawn snapshot) + { + ServerGuid = snapshot.Guid; + Snapshot = snapshot; + RefreshDerivedState(); + } + + public uint ServerGuid { get; } + public ushort Generation => Snapshot.InstanceSequence; + public WorldSession.EntitySpawn Snapshot { get; internal set; } + public WorldEntity? WorldEntity { get; internal set; } + public uint? LocalEntityId => WorldEntity?.Id; + public uint FullCellId { get; internal set; } + public uint CanonicalLandblockId { get; internal set; } + public uint RawPhysicsState { get; internal set; } + public PhysicsStateFlags FinalPhysicsState { get; internal set; } + public PhysicsBody? PhysicsBody { get; internal set; } + public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } + public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } + public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } + public ILiveEntityEffectProfile? EffectProfile { get; internal set; } + public bool ResourcesRegistered { get; internal set; } + public bool IsSpatiallyProjected { get; internal set; } + public bool IsSpatiallyVisible { get; internal set; } + public bool WorldSpawnPublished { get; internal set; } + public LiveEntityProjectionKind ProjectionKind { get; internal set; } + + internal void RefreshDerivedState(bool refreshPosition = true) + { + if (refreshPosition && Snapshot.Position is { } position) + { + FullCellId = position.LandblockId; + CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu; + } + RawPhysicsState = Snapshot.Physics?.RawState + ?? Snapshot.PhysicsState + ?? 0u; + FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState; + } +} + +public readonly record struct LiveEntityRegistrationResult( + InboundCreateResult Inbound, + LiveEntityRecord? Record, + bool LogicalRegistrationCreated, + bool ReplacedExistingGeneration, + Exception? PriorGenerationCleanupFailure = null); + +/// +/// Update-thread owner of live server-object identity, accepted state, runtime +/// components, and logical teardown. is a spatial +/// projection only; moving between loaded and pending buckets never replays a +/// create-time renderer, script, animation, or effect action. +/// +/// +/// Retail keeps one object per accepted INSTANCE_TS: same-generation +/// CreateObject branches mutate it, Pickup leaves world without destruction, +/// and Delete/instance replacement end it. See SmartBox CreateObject/event +/// pseudocode in 2026-07-13-retail-projectile-vfx-pseudocode.md, +/// CPhysicsObj::set_description (0x00514F40), +/// CPhysicsObj::change_cell (0x00513390), and +/// CPhysicsObj::exit_world (0x00514E60). +/// +/// +public sealed class LiveEntityRuntime +{ + public const uint FirstLiveEntityId = 1_000_000u; + public const uint LastLiveEntityId = 0x3FFF_FFFFu; + + private readonly GpuWorldState _spatial; + private readonly ILiveEntityResourceLifecycle _resources; + private readonly Action? _tearDownRuntimeComponents; + private readonly InboundPhysicsStateController _inbound = new(); + private readonly Dictionary _recordsByGuid = new(); + private readonly Dictionary _materializedWorldEntitiesByGuid = new(); + private readonly Dictionary _visibleWorldEntitiesByGuid = new(); + private readonly Dictionary _guidByLocalId = new(); + private readonly Dictionary _animationsByLocalId = new(); + private readonly Dictionary _remoteMotionByGuid = new(); + private uint _nextLocalEntityId; + + public LiveEntityRuntime( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + Action? tearDownRuntimeComponents = null, + uint firstLocalEntityId = FirstLiveEntityId) + { + _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _tearDownRuntimeComponents = tearDownRuntimeComponents; + if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId) + throw new ArgumentOutOfRangeException( + nameof(firstLocalEntityId), + $"Live entity ids must stay in 0x{FirstLiveEntityId:X8}..0x{LastLiveEntityId:X8}."); + _nextLocalEntityId = firstLocalEntityId; + _spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged; + } + + public int Count => _recordsByGuid.Count; + public int MaterializedCount => _guidByLocalId.Count; + public IReadOnlyCollection Records => _recordsByGuid.Values; + /// + /// Every materialized top-level world projection, including an object + /// parked in a pending (currently unloaded) spatial bucket. Network and + /// physics mutation must use this stable view so streaming visibility can + /// never masquerade as logical destruction. + /// + public IReadOnlyDictionary MaterializedWorldEntities => + _materializedWorldEntitiesByGuid; + + /// + /// Currently visible top-level world projections. Attached children and + /// pending projections are excluded from radar, picking, status, and target + /// candidate consumers. + /// + public IReadOnlyDictionary WorldEntities => + _visibleWorldEntitiesByGuid; + public IReadOnlyDictionary Snapshots => _inbound.Snapshots; + public IReadOnlyDictionary AnimationRuntimes => _animationsByLocalId; + public IReadOnlyDictionary RemoteMotionRuntimes => _remoteMotionByGuid; + public ParentAttachmentState ParentAttachments { get; } = new(); + + public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) + { + InboundCreateResult result = _inbound.AcceptCreate(incoming); + if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) + return new LiveEntityRegistrationResult(result, null, false, false); + + if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration) + { + if (_recordsByGuid.TryGetValue(incoming.Guid, out LiveEntityRecord? retained)) + { + retained.Snapshot = result.Snapshot; + retained.RefreshDerivedState(); + return new LiveEntityRegistrationResult(result, retained, false, false); + } + + // Defensive repair for a caller that cleared only the logical map. + // Normal session teardown clears both owners together. + var recovered = new LiveEntityRecord(result.Snapshot); + _recordsByGuid.Add(incoming.Guid, recovered); + return new LiveEntityRegistrationResult(result, recovered, true, false); + } + + bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old); + Exception? tearDownFailure = null; + if (old is not null) + { + try + { + TearDownRecord(old); + } + catch (Exception error) + { + tearDownFailure = error; + } + } + + if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration) + ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence); + + var record = new LiveEntityRecord(result.Snapshot); + _recordsByGuid.Add(incoming.Guid, record); + return new LiveEntityRegistrationResult( + result, + record, + true, + replaced, + tearDownFailure); + } + + /// + /// Completes logical registration when a renderable projection can be + /// hydrated. The factory is called at most once for the incarnation. + /// + public WorldEntity? MaterializeLiveEntity( + uint serverGuid, + uint fullCellId, + Func factory, + LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World) + { + ArgumentNullException.ThrowIfNull(factory); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) + return null; + + if (record.WorldEntity is null) + { + uint localId = ReserveLocalEntityId(); + WorldEntity entity = factory(localId) + ?? throw new InvalidOperationException("A live-entity projection factory returned null."); + if (entity.Id != localId) + throw new InvalidOperationException( + $"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}."); + if (entity.ServerGuid != serverGuid) + throw new InvalidOperationException( + $"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}."); + + _guidByLocalId.Add(localId, serverGuid); + record.WorldEntity = entity; + try + { + _resources.Register(entity); + record.ResourcesRegistered = true; + } + catch (Exception registrationError) + { + // Registration is an atomic logical-lifetime boundary. Give + // composite owners a symmetric rollback opportunity, then + // remove the identity even if cleanup itself fails. + try + { + _resources.Unregister(entity); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Live entity resource registration and rollback both failed.", + registrationError, + rollbackError); + } + finally + { + _guidByLocalId.Remove(localId); + record.WorldEntity = null; + record.ResourcesRegistered = false; + } + throw; + } + } + + record.ProjectionKind = projectionKind; + RebucketLiveEntity(serverGuid, fullCellId); + return record.WorldEntity; + } + + /// Changes spatial buckets only. No logical resource callbacks run. + public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + return false; + + _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId); + bool visible = _spatial.IsLiveEntityVisible(serverGuid); + record.IsSpatiallyVisible = visible; + if (record.ProjectionKind is LiveEntityProjectionKind.World) + _materializedWorldEntitiesByGuid[serverGuid] = entity; + else + _materializedWorldEntitiesByGuid.Remove(serverGuid); + if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) + _visibleWorldEntitiesByGuid[serverGuid] = entity; + else + _visibleWorldEntitiesByGuid.Remove(serverGuid); + if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) + record.FullCellId = spatialCellOrLandblockId; + record.CanonicalLandblockId = spatialCellOrLandblockId == 0 + ? 0u + : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + record.IsSpatiallyProjected = true; + return true; + } + + /// + /// Removes only the render-bucket reference. The logical record and every + /// create-time resource remain alive for later projection/rebucketing. + /// + public bool WithdrawLiveEntityProjection(uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is null) + return false; + + _spatial.RemoveLiveEntityProjection(serverGuid); + _materializedWorldEntitiesByGuid.Remove(serverGuid); + _visibleWorldEntitiesByGuid.Remove(serverGuid); + record.IsSpatiallyProjected = false; + record.IsSpatiallyVisible = false; + return true; + } + + public bool UnregisterLiveEntity( + DeleteObject.Parsed delete, + bool isLocalPlayer, + Action? beforeTeardown = null) + { + if (!_inbound.TryDelete(delete, isLocalPlayer)) + return false; + + ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); + + List? failures = null; + try + { + beforeTeardown?.Invoke(); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + + if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record)) + { + try + { + TearDownRecord(record); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + + if (failures is not null) + throw new AggregateException( + $"Live entity 0x{delete.Guid:X8} deletion cleanup failed.", + failures); + return true; + } + + public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) => + _recordsByGuid.TryGetValue(serverGuid, out record!); + + public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.WorldEntity is { } found) + { + entity = found; + return true; + } + + entity = null!; + return false; + } + + public bool ContainsWorldEntity(uint serverGuid) => + _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.WorldEntity is not null; + + public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) => + _guidByLocalId.TryGetValue(localEntityId, out serverGuid); + + public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.LocalEntityId is { } found) + { + localEntityId = found; + return true; + } + + localEntityId = 0; + return false; + } + + public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized."); + if (!ReferenceEquals(runtime.Entity, entity)) + throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity."); + + if (record.AnimationRuntime is not null) + _animationsByLocalId.Remove(entity.Id); + record.AnimationRuntime = runtime; + _animationsByLocalId[entity.Id] = runtime; + } + + public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) => + _animationsByLocalId.TryGetValue(localEntityId, out runtime!); + + public bool ClearAnimationRuntime(uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.AnimationRuntime is null) + return false; + if (record.LocalEntityId is { } localId) + _animationsByLocalId.Remove(localId); + record.AnimationRuntime = null; + return true; + } + + public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) + throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); + record.RemoteMotionRuntime = runtime; + record.PhysicsBody = runtime.Body; + _remoteMotionByGuid[serverGuid] = runtime; + } + + public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => + _remoteMotionByGuid.TryGetValue(serverGuid, out runtime!); + + public bool ClearRemoteMotionRuntime(uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.RemoteMotionRuntime is null) + return false; + _remoteMotionByGuid.Remove(serverGuid); + record.RemoteMotionRuntime = null; + record.PhysicsBody = null; + return true; + } + + public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => + _inbound.TryGetSnapshot(guid, out spawn); + + public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyObjDesc(update, out accepted); + if (applied) RefreshRecord(update.Guid, accepted); + return applied; + } + + public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyPickup(update, out accepted); + if (applied) + { + RefreshRecord(update.Guid, accepted); + ClearWorldCell(update.Guid); + ParentAttachments.EndChildProjection(update.Guid); + } + return applied; + } + + public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyCreateParent(update, out accepted); + if (applied) + { + RefreshRecord(update.ChildGuid, accepted); + ClearWorldCell(update.ChildGuid); + } + return applied; + } + + public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyParent(update, out accepted); + if (applied) + { + RefreshRecord(update.ChildGuid, accepted); + ClearWorldCell(update.ChildGuid); + } + return applied; + } + + public bool TryApplyMotion( + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted, + out AcceptedPhysicsTimestamps timestamps) + { + bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps); + if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) + RefreshRecord(update.Guid, snapshot); + return applied; + } + + public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyVector(update, out accepted); + if (applied) RefreshRecord(update.Guid, accepted); + return applied; + } + + public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted) + { + bool applied = _inbound.TryApplyState(update, out accepted); + if (applied) RefreshRecord(update.Guid, accepted); + return applied; + } + + public bool TryApplyPosition( + WorldSession.EntityPositionUpdate update, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out AcceptedPhysicsTimestamps timestamps) + { + bool known = _inbound.TryApplyPosition( + update, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + out disposition, + out accepted, + out timestamps); + if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) + { + bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; + RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition); + if (acceptedPosition) + { + ParentAttachments.EndChildProjection(update.Guid); + } + } + return known; + } + + public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) => + _inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence); + + /// + /// Retail runs root movement/animation only while the object has world-cell + /// membership. A pickup or parent transition keeps script/effect owners but + /// clears the cell and withdraws the root projection until a fresh Position + /// re-enters it. + /// + public bool ShouldAdvanceRootRuntime(uint serverGuid) => + _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.IsSpatiallyProjected + && record.FullCellId != 0; + + /// + /// Claims the one logical top-level spawn notification for this object + /// incarnation. Spatial re-entry must restore presentation without replaying + /// plugin/event registration. An attached-only child claims nothing until it + /// first becomes a top-level world projection. + /// + public bool TryMarkWorldSpawnPublished(uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is null + || record.ProjectionKind is not LiveEntityProjectionKind.World + || record.WorldSpawnPublished) + return false; + record.WorldSpawnPublished = true; + return true; + } + + public void Clear() + { + List? failures = null; + foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray()) + { + try + { + TearDownRecord(record); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + _recordsByGuid.Clear(); + _materializedWorldEntitiesByGuid.Clear(); + _visibleWorldEntitiesByGuid.Clear(); + _guidByLocalId.Clear(); + _animationsByLocalId.Clear(); + _remoteMotionByGuid.Clear(); + ParentAttachments.Clear(); + _inbound.Clear(); + + if (failures is not null) + throw new AggregateException("One or more live entities failed session teardown.", failures); + } + + private void RefreshRecord( + uint guid, + WorldSession.EntitySpawn accepted, + bool refreshPosition = false) + { + if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) + return; + record.Snapshot = accepted; + record.RefreshDerivedState(refreshPosition); + } + + private void ClearWorldCell(uint guid) + { + if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) + return; + record.FullCellId = 0; + record.CanonicalLandblockId = 0; + } + + private uint ReserveLocalEntityId() + { + uint start = _nextLocalEntityId; + do + { + uint candidate = _nextLocalEntityId; + _nextLocalEntityId = candidate == LastLiveEntityId + ? FirstLiveEntityId + : candidate + 1u; + if (!_guidByLocalId.ContainsKey(candidate)) + return candidate; + } + while (_nextLocalEntityId != start); + + throw new InvalidOperationException("The live entity id namespace is exhausted."); + } + + private void OnSpatialVisibilityChanged(uint serverGuid, bool visible) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + return; + + record.IsSpatiallyVisible = visible; + if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) + _visibleWorldEntitiesByGuid[serverGuid] = entity; + else + _visibleWorldEntitiesByGuid.Remove(serverGuid); + } + + private void TearDownRecord(LiveEntityRecord record) + { + List? failures = null; + void RunCleanup(Action cleanup) + { + try + { + cleanup(); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + + if (_tearDownRuntimeComponents is not null) + RunCleanup(() => _tearDownRuntimeComponents(record)); + + if (record.WorldEntity is { } entity) + { + RunCleanup(() => _spatial.RemoveLiveEntityProjection(record.ServerGuid)); + if (record.ResourcesRegistered) + RunCleanup(() => _resources.Unregister(entity)); + _guidByLocalId.Remove(entity.Id); + _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); + _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + _animationsByLocalId.Remove(entity.Id); + } + + _remoteMotionByGuid.Remove(record.ServerGuid); + record.AnimationRuntime = null; + record.RemoteMotionRuntime = null; + record.PhysicsBody = null; + record.ProjectileRuntime = null; + record.EffectProfile = null; + record.ResourcesRegistered = false; + record.IsSpatiallyProjected = false; + record.IsSpatiallyVisible = false; + record.WorldSpawnPublished = false; + record.WorldEntity = null; + + if (failures is not null) + throw new AggregateException( + $"Live entity 0x{record.ServerGuid:X8} teardown failed.", + failures); + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs new file mode 100644 index 00000000..325b11f3 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs @@ -0,0 +1,97 @@ +namespace AcDream.App.World; + +/// +/// Strongly typed, storage-free view over animation components owned by a +/// . This keeps feature loops type-safe without +/// introducing a second component dictionary. +/// +public sealed class LiveEntityAnimationRuntimeView + : IEnumerable> + where TAnimation : class, ILiveEntityAnimationRuntime +{ + private readonly Func _runtime; + + public LiveEntityAnimationRuntimeView(Func runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public int Count => _runtime()?.AnimationRuntimes.Count ?? 0; + public IEnumerable Keys => + _runtime()?.AnimationRuntimes.Keys ?? Array.Empty(); + + public TAnimation this[uint localEntityId] + { + set + { + LiveEntityRuntime runtime = _runtime() + ?? throw new InvalidOperationException("Live entity runtime is not initialized."); + if (!runtime.TryGetServerGuid(localEntityId, out uint guid)) + throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}."); + runtime.SetAnimationRuntime(guid, value); + } + } + + public bool TryGetValue(uint localEntityId, out TAnimation animation) + { + if (_runtime()?.TryGetAnimationRuntime(localEntityId, out var found) == true + && found is TAnimation typed) + { + animation = typed; + return true; + } + + animation = null!; + return false; + } + + public bool Remove(uint localEntityId) + { + LiveEntityRuntime? runtime = _runtime(); + return runtime is not null + && runtime.TryGetServerGuid(localEntityId, out uint guid) + && runtime.ClearAnimationRuntime(guid); + } + + public IEnumerator> GetEnumerator() + { + if (_runtime() is not { } runtime) + yield break; + foreach (var pair in runtime.AnimationRuntimes) + if (pair.Value is TAnimation typed) + yield return new KeyValuePair(pair.Key, typed); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// Storage-free typed view over remote-motion components. +public sealed class LiveEntityRemoteMotionRuntimeView + where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime +{ + private readonly Func _runtime; + + public LiveEntityRemoteMotionRuntimeView(Func runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public TRemoteMotion this[uint serverGuid] + { + set => (_runtime() + ?? throw new InvalidOperationException("Live entity runtime is not initialized.")) + .SetRemoteMotionRuntime(serverGuid, value); + } + + public bool TryGetValue(uint serverGuid, out TRemoteMotion motion) + { + if (_runtime()?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true + && found is TRemoteMotion typed) + { + motion = typed; + return true; + } + + motion = null!; + return false; + } + + public bool Remove(uint serverGuid) => + _runtime()?.ClearRemoteMotionRuntime(serverGuid) == true; +} diff --git a/src/AcDream.App/Rendering/ParentAttachmentState.cs b/src/AcDream.App/World/ParentAttachmentState.cs similarity index 99% rename from src/AcDream.App/Rendering/ParentAttachmentState.cs rename to src/AcDream.App/World/ParentAttachmentState.cs index 043c0991..04fa4e7f 100644 --- a/src/AcDream.App/Rendering/ParentAttachmentState.cs +++ b/src/AcDream.App/World/ParentAttachmentState.cs @@ -1,7 +1,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; -namespace AcDream.App.Rendering; +namespace AcDream.App.World; /// /// Update-thread state machine for parent relations that may arrive before diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs new file mode 100644 index 00000000..a28e1ca7 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -0,0 +1,876 @@ +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityRuntimeTests +{ + private sealed class RecordingResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + public void Register(WorldEntity entity) => RegisterCount++; + public void Unregister(WorldEntity entity) => UnregisterCount++; + } + + private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime + { + public WorldEntity Entity { get; } = entity; + } + + private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + + public void Register(WorldEntity entity) + { + RegisterCount++; + throw new InvalidOperationException("fixture registration failure"); + } + + public void Unregister(WorldEntity entity) => UnregisterCount++; + } + + private sealed class FailingUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle + { + public int UnregisterCount { get; private set; } + + public void Register(WorldEntity entity) { } + + public void Unregister(WorldEntity entity) + { + UnregisterCount++; + if (entity.ServerGuid == failingGuid) + throw new InvalidOperationException("fixture unregister failure"); + } + } + + [Fact] + public void RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000001u, 1, 1, 0x01010001u); + + LiveEntityRegistrationResult registered = runtime.RegisterLiveEntity(spawn); + WorldEntity? entity = runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid)); + runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!)); + + Assert.True(registered.LogicalRegistrationCreated); + Assert.Equal(1, resources.RegisterCount); + Assert.Single(spatial.Entities); + Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u)); + Assert.Equal(1, resources.RegisterCount); + Assert.Single(spatial.Entities); + Assert.True(runtime.WithdrawLiveEntityProjection(spawn.Guid)); + Assert.Empty(spatial.Entities); + Assert.Empty(runtime.WorldEntities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); + Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01010001u)); + Assert.Single(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); + } + + [Fact] + public void InitialChildCreate_PreservesParentEventQueuedForMissingChild() + { + const uint parentGuid = 0x70000020u; + const uint childGuid = 0x70000021u; + var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u)); + runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed( + parentGuid, childGuid, 1, 2, 9, 5)); + ResolveParent(runtime, childGuid); + + runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u)); + ResolveParent(runtime, childGuid); + + Assert.True(runtime.ParentAttachments.TryGetProjection( + childGuid, out ParentAttachmentRelation relation)); + Assert.Equal(parentGuid, relation.ParentGuid); + Assert.True(runtime.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); + Assert.Null(child.Position); + } + + [Fact] + public void InitialParentCreate_PreservesCreateObjectRelationWaitingForParent() + { + const uint parentGuid = 0x70000022u; + const uint childGuid = 0x70000023u; + var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u)); + runtime.ParentAttachments.AcceptCreateObjectRelation(new ParentAttachmentRelation( + parentGuid, childGuid, 1, 2, 9, 4)); + + runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u)); + + Assert.True(runtime.ParentAttachments.TryGetProjection( + childGuid, out ParentAttachmentRelation relation)); + Assert.Equal(parentGuid, relation.ParentGuid); + } + + [Fact] + public void SameGenerationCreate_DoesNotReconstructProjection() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn first = Spawn(0x70000002u, 4, 10, 0x01010001u); + runtime.RegisterLiveEntity(first); + WorldEntity original = runtime.MaterializeLiveEntity( + first.Guid, + first.Position!.Value.LandblockId, + id => Entity(id, first.Guid))!; + + LiveEntityRegistrationResult refresh = runtime.RegisterLiveEntity( + Spawn(first.Guid, 4, 11, 0x01010001u)); + WorldEntity retained = runtime.MaterializeLiveEntity( + first.Guid, + first.Position.Value.LandblockId, + _ => throw new InvalidOperationException("same generation reconstructed"))!; + + Assert.False(refresh.LogicalRegistrationCreated); + Assert.Same(original, retained); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void NewGeneration_TearsDownExactlyOnceAndReusesNoLocalIdentity() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + int runtimeTearDowns = 0; + var runtime = new LiveEntityRuntime( + spatial, + resources, + _ => runtimeTearDowns++); + const uint guid = 0x70000003u; + + runtime.RegisterLiveEntity(Spawn(guid, 9, 1, 0x01010001u)); + WorldEntity old = runtime.MaterializeLiveEntity( + guid, 0x01010001u, id => Entity(id, guid))!; + LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity( + Spawn(guid, 10, 1, 0x01010001u)); + WorldEntity current = runtime.MaterializeLiveEntity( + guid, 0x01010001u, id => Entity(id, guid))!; + + Assert.True(replacement.ReplacedExistingGeneration); + Assert.NotEqual(old.Id, current.Id); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(1, runtimeTearDowns); + Assert.Single(spatial.Entities); + Assert.Same(current, spatial.Entities[0]); + Assert.False(runtime.TryGetServerGuid(old.Id, out _)); + Assert.True(runtime.TryGetServerGuid(current.Id, out uint mapped)); + Assert.Equal(guid, mapped); + } + + [Fact] + public void AppearanceUpdate_MutatesSnapshotWithoutChangingIdentity() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000004u, 2, 3, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + spawn.Guid, 0x01010001u, id => Entity(id, spawn.Guid))!; + + var update = new ObjDescEvent.Parsed( + spawn.Guid, + new CreateObject.ModelData( + 0x04000001u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 2, + ObjDescSequence: 2); + Assert.True(runtime.TryApplyObjDesc(update, out _)); + + Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)); + Assert.Same(entity, record.WorldEntity); + Assert.Equal((uint)0x04000001u, record.Snapshot.BasePaletteId); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void DeleteAndGuidReuse_HaveSeparateLogicalLifetimes() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + const uint guid = 0x70000005u; + runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u)); + WorldEntity first = runtime.MaterializeLiveEntity( + guid, 0x01010001u, id => Entity(id, guid))!; + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, 3), + isLocalPlayer: false)); + Assert.Empty(spatial.Entities); + Assert.False(runtime.TryGetRecord(guid, out _)); + + runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u)); + WorldEntity second = runtime.MaterializeLiveEntity( + guid, 0x01010001u, id => Entity(id, guid))!; + + Assert.NotEqual(first.Id, second.Id); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void AttachedProjection_RendersWithoutEnteringTopLevelWorldIndex() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000006u, 3, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + + WorldEntity attached = runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid), + LiveEntityProjectionKind.Attached)!; + + Assert.Single(spatial.Entities); + Assert.Empty(runtime.WorldEntities); + Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); + Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved)); + Assert.Same(attached, resolved); + Assert.Equal(1, resources.RegisterCount); + + WorldEntity same = runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position.Value.LandblockId, + _ => throw new InvalidOperationException("attachment reconstructed"), + LiveEntityProjectionKind.World)!; + + Assert.Same(attached, same); + Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value); + Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); + Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); + Assert.Equal(1, resources.RegisterCount); + } + + [Fact] + public void PendingToLoadedTransition_DoesNotReplayLogicalRegistration() + { + var spatial = new GpuWorldState(); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000007u, 1, 1, 0x01020001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid))!; + + Assert.Empty(spatial.Entities); + Assert.Equal(1, spatial.PendingLiveEntityCount); + Assert.Equal(1, resources.RegisterCount); + Assert.Empty(runtime.WorldEntities); + Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + Assert.Same(entity, Assert.Single(spatial.Entities)); + Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u)); + Assert.Same(entity, Assert.Single(spatial.Entities)); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void PendingProjection_RemainsCanonicalAcrossAuthoritativeUpdatesWithoutReregister() + { + const uint guid = 0x70000025u; + var spatial = new GpuWorldState(); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(guid, 2, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid))!; + + Assert.Empty(runtime.WorldEntities); + Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.True(runtime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + new System.Numerics.Vector3(2f, 3f, 4f), + System.Numerics.Vector3.Zero, + spawn.InstanceSequence, + 2), + out _)); + var positionUpdate = new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + 0x01020001u, 20f, 30f, 6f, 1f, 0f, 0f, 0f), + null, + null, + true, + spawn.InstanceSequence, + 2, + 0, + 0); + Assert.True(runtime.TryApplyPosition( + positionUpdate, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.True(runtime.RebucketLiveEntity(guid, accepted.Position!.Value.LandblockId)); + + Assert.Empty(runtime.WorldEntities); + Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + Assert.Equal(1, spatial.PendingLiveEntityCount); + } + + [Fact] + public void PickupLeaveWorld_PreservesOwnersButPausesRootUntilPositionReentry() + { + const uint guid = 0x70000026u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(guid, 4, 10, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid))!; + var animation = new AnimationRuntime(entity); + runtime.SetAnimationRuntime(guid, animation); + uint localId = entity.Id; + Assert.True(runtime.TryMarkWorldSpawnPublished(guid)); + + Assert.True(runtime.TryApplyPickup( + new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11), + out _)); + Assert.True(runtime.WithdrawLiveEntityProjection(guid)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0u, record.FullCellId); + Assert.Equal(0u, record.CanonicalLandblockId); + Assert.False(runtime.ShouldAdvanceRootRuntime(guid)); + Assert.True(runtime.TryGetAnimationRuntime(localId, out var retainedAnimation)); + Assert.Same(animation, retainedAnimation); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + + var positionUpdate = new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + 0x01010002u, 30f, 40f, 7f, 1f, 0f, 0f, 0f), + null, + null, + true, + spawn.InstanceSequence, + 12, + 0, + 0); + Assert.True(runtime.TryApplyPosition( + positionUpdate, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + WorldEntity same = runtime.MaterializeLiveEntity( + guid, + accepted.Position!.Value.LandblockId, + _ => throw new InvalidOperationException("re-entry reconstructed the projection"))!; + + Assert.Same(entity, same); + Assert.Equal(localId, same.Id); + Assert.False(runtime.TryMarkWorldSpawnPublished(guid)); + Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); + Assert.Equal(0x01010002u, record.FullCellId); + Assert.True(runtime.TryGetAnimationRuntime(localId, out retainedAnimation)); + Assert.Same(animation, retainedAnimation); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000024u, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + spawn.Guid, + 0x01010001u, + id => Entity(id, spawn.Guid))!; + + spatial.RemoveLandblock(0x0101FFFFu); + + Assert.Empty(runtime.WorldEntities); + Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)); + Assert.True(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.Equal(1, spatial.PendingLiveEntityCount); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal(1, resources.RegisterCount); + } + + [Fact] + public void LoadedToPendingToLoaded_RemovesOldDrawSlotWithoutLogicalRecreate() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x70000009u, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid))!; + + Assert.Same(entity, Assert.Single(spatial.Entities)); + Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u)); + Assert.Empty(spatial.Entities); + Assert.Equal(1, spatial.PendingLiveEntityCount); + Assert.Equal(1, resources.RegisterCount); + + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + Assert.Same(entity, Assert.Single(spatial.Entities)); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void SessionClear_IsSymmetricAndIdempotent() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + int runtimeTearDowns = 0; + var runtime = new LiveEntityRuntime(spatial, resources, _ => runtimeTearDowns++); + WorldSession.EntitySpawn spawn = Spawn(0x70000008u, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid)); + + runtime.Clear(); + runtime.Clear(); + + Assert.Equal(0, runtime.Count); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(1, runtimeTearDowns); + } + + [Fact] + public void ResourceRegistrationFailure_RollsBackMaterializedIdentityAndSpatialState() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingRegisterResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x7000000Au, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + + Assert.Throws(() => runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid))); + + Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)); + Assert.Null(record.WorldEntity); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void SessionClear_AttemptsEveryTeardownAndClearsIdentityWhenOneResourceFails() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingUnregisterResources(0x7000000Bu); + var runtime = new LiveEntityRuntime(spatial, resources); + foreach (uint guid in new[] { 0x7000000Bu, 0x7000000Cu }) + { + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + runtime.MaterializeLiveEntity( + guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid)); + } + + Assert.Throws(() => runtime.Clear()); + + Assert.Equal(2, resources.UnregisterCount); + Assert.Equal(0, runtime.Count); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(spatial.Entities); + } + + [Fact] + public void GenerationReplacement_InstallsNewRecordBeforeReportingOldCleanupFailure() + { + const uint guid = 0x7000000Eu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingUnregisterResources(guid); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn first = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(first); + runtime.MaterializeLiveEntity( + guid, + first.Position!.Value.LandblockId, + id => Entity(id, guid)); + + LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.NotNull(replacement.PriorGenerationCleanupFailure); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal((ushort)2, record.Generation); + Assert.Null(record.WorldEntity); + WorldEntity installed = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + Assert.Equal(guid, installed.ServerGuid); + Assert.Single(spatial.Entities); + } + + [Fact] + public void CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell() + { + const uint guid = 0x7000000Fu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010022u)); + runtime.MaterializeLiveEntity(guid, 0x01010022u, id => Entity(id, guid)); + + runtime.RebucketLiveEntity(guid, 0x0102FFFFu); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0x01010022u, record.FullCellId); + Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId); + + runtime.RebucketLiveEntity(guid, 0x01020033u); + Assert.Equal(0x01020033u, record.FullCellId); + } + + [Fact] + public void AttachedProjection_CrossesLandblocksWithoutChangingIdentityOrResources() + { + const uint guid = 0x70000010u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity attached = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid), + LiveEntityProjectionKind.Attached)!; + + runtime.RebucketLiveEntity(guid, 0x01020001u); + + Assert.Same(attached, Assert.Single(spatial.Entities)); + Assert.Empty(runtime.WorldEntities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + var destination = Assert.Single( + spatial.LandblockEntries, + entry => entry.LandblockId == 0x0102FFFFu); + Assert.Same(attached, Assert.Single(destination.Entities)); + } + + [Fact] + public void LocalIdAllocator_WrapsInsideLiveNamespaceWithoutReturningZero() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime( + spatial, + new RecordingResources(), + firstLocalEntityId: LiveEntityRuntime.LastLiveEntityId); + WorldSession.EntitySpawn first = Spawn(0x70000011u, 1, 1, 0x01010001u); + WorldSession.EntitySpawn second = Spawn(0x70000012u, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(first); + runtime.RegisterLiveEntity(second); + + WorldEntity firstEntity = runtime.MaterializeLiveEntity( + first.Guid, 0x01010001u, id => Entity(id, first.Guid))!; + WorldEntity secondEntity = runtime.MaterializeLiveEntity( + second.Guid, 0x01010001u, id => Entity(id, second.Guid))!; + + Assert.Equal(LiveEntityRuntime.LastLiveEntityId, firstEntity.Id); + Assert.Equal(LiveEntityRuntime.FirstLiveEntityId, secondEntity.Id); + Assert.NotEqual(0u, secondEntity.Id); + Assert.True(runtime.TryGetLocalEntityId(second.Guid, out uint resolved)); + Assert.Equal(secondEntity.Id, resolved); + Assert.Throws(() => new LiveEntityRuntime( + new GpuWorldState(), + new RecordingResources(), + firstLocalEntityId: uint.MaxValue)); + } + + [Fact] + public void Delete_StopsScriptsOwnedByCanonicalLocalId_NotServerGuid() + { + const uint serverGuid = 0x70000013u; + const uint scriptId = 0x33000001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 60d, + Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u }, + }); + var particleSystem = new ParticleSystem(new EmitterDescRegistry()); + var particleSink = new ParticleHookSink(particleSystem); + var runner = new PhysicsScriptRunner( + id => id == scriptId ? script : null, + particleSink); + var activator = new EntityScriptActivator( + runner, + particleSink, + _ => new ScriptActivationInfo(scriptId, Array.Empty())); + var runtime = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle( + activator.OnCreate, + entity => activator.OnRemove(entity.Id)), + record => activator.OnRemoveLegacyOwner( + record.ServerGuid, + record.LocalEntityId ?? 0u)); + WorldSession.EntitySpawn spawn = Spawn(serverGuid, 7, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + + // Current AD-32 behavior before Step 4's pending FIFO: an F754 that + // precedes materialization temporarily starts under server GUID. + Assert.True(activator.PlayLegacyPending( + serverGuid, + scriptId, + System.Numerics.Vector3.Zero)); + Assert.Equal(1, runner.ActiveScriptCount); + + WorldEntity entity = runtime.MaterializeLiveEntity( + serverGuid, + spawn.Position!.Value.LandblockId, + id => Entity(id, serverGuid))!; + + Assert.NotEqual(serverGuid, entity.Id); + Assert.Equal(2, runner.ActiveScriptCount); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(serverGuid, spawn.InstanceSequence), + isLocalPlayer: false)); + + Assert.Equal(0, runner.ActiveScriptCount); + } + + [Fact] + public void StaleDelete_DoesNotStopCurrentGenerationPreMaterializationF754Alias() + { + const uint serverGuid = 0x70000027u; + const uint scriptId = 0x33000001u; + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 60d, + Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u }, + }); + var particleSink = new ParticleHookSink( + new ParticleSystem(new EmitterDescRegistry())); + var runner = new PhysicsScriptRunner( + id => id == scriptId ? script : null, + particleSink); + var activator = new EntityScriptActivator( + runner, + particleSink, + _ => null); + var runtime = new LiveEntityRuntime( + new GpuWorldState(), + new RecordingResources(), + record => activator.OnRemoveLegacyOwner( + record.ServerGuid, + record.LocalEntityId ?? 0u)); + WorldSession.EntitySpawn spawn = Spawn(serverGuid, 10, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + Assert.True(activator.PlayLegacyPending( + serverGuid, + scriptId, + System.Numerics.Vector3.Zero)); + + Assert.False(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(serverGuid, 9), + isLocalPlayer: false)); + Assert.Equal(1, runner.ActiveScriptCount); + Assert.Equal(1, activator.LegacyPendingOwnerCount); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(serverGuid, 10), + isLocalPlayer: false)); + Assert.Equal(0, runner.ActiveScriptCount); + Assert.Equal(0, activator.LegacyPendingOwnerCount); + } + + [Fact] + public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(0x7000000Du, 4, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, spawn.Guid)); + + Assert.Throws(() => runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), + isLocalPlayer: false, + beforeTeardown: () => throw new InvalidOperationException("fixture callback failure"))); + + Assert.Equal(0, runtime.Count); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.UnregisterCount); + } + + private static LoadedLandblock EmptyLandblock(uint canonicalId) => + new(canonicalId, new LandBlock(), Array.Empty()); + + private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) => + runtime.ParentAttachments.Resolve( + childGuid, + guid => runtime.TryGetSnapshot(guid, out _), + guid => runtime.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn) + ? spawn.InstanceSequence + : null, + update => runtime.TryApplyParent(update, out _)); + + private static WorldEntity Entity(uint id, uint guid) => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + ushort positionSequence, + uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + positionSequence, 1, 1, 1, 0, 1, 0, 1, instance); + 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, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: positionSequence, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs index b20a63e1..176808f4 100644 --- a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs +++ b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs @@ -1,4 +1,3 @@ -using AcDream.App.Rendering; using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.Net.Messages; diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 4a5bd3fe..98277a51 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -324,4 +324,42 @@ public sealed class EntityScriptActivatorTests system.Tick(0.01f); Assert.Equal(0, system.ActiveEmitterCount); } + + [Fact] + public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias() + { + const uint serverGuid = 0x7000CAFEu; + const uint localId = 0x00100001u; + var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u })); + var p = BuildPipeline((0xAAu, script)); + var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); + + // This is the current AD-32 pre-materialization F754 path: no local ID + // exists yet, so the direct PES is temporarily owned by server GUID. + Assert.True(activator.PlayLegacyPending(serverGuid, 0xAAu, Vector3.Zero)); + Assert.Equal(1, p.Runner.ActiveScriptCount); + Assert.Equal(1, activator.LegacyPendingOwnerCount); + + activator.OnRemoveLegacyOwner(serverGuid, localId); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.Equal(0, activator.LegacyPendingOwnerCount); + } + + [Fact] + public void ClearLegacyPendingOwners_StopsF754AliasesWithoutLiveRecords() + { + var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u })); + var p = BuildPipeline((0xAAu, script)); + var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); + Assert.True(activator.PlayLegacyPending(0x70000001u, 0xAAu, Vector3.Zero)); + Assert.True(activator.PlayLegacyPending(0x70000002u, 0xAAu, Vector3.Zero)); + Assert.Equal(2, p.Runner.ActiveScriptCount); + Assert.Equal(2, activator.LegacyPendingOwnerCount); + + activator.ClearLegacyPendingOwners(); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.Equal(0, activator.LegacyPendingOwnerCount); + } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs index c5d47f75..c59e04bc 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs @@ -34,8 +34,8 @@ public sealed class PendingSpawnIntegrationTests // the landblock streams in. ServerGuid != 0 makes this per-instance-tier. var liveEntity = MakeServerSpawned( id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); - // AppendLiveEntity takes the raw cell-form id; it canonicalises internally. - state.AppendLiveEntity(0x12340011u, liveEntity); + // Projection placement takes the raw cell-form id; it canonicalises internally. + state.PlaceLiveEntityProjection(0x12340011u, liveEntity); Assert.Equal(1, state.PendingLiveEntityCount); Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded @@ -86,9 +86,9 @@ public sealed class PendingSpawnIntegrationTests // Now a live entity arrives — landblock is already loaded. var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); - state.AppendLiveEntity(0x12340022u, liveEntity); + state.PlaceLiveEntityProjection(0x12340022u, liveEntity); - // Adapter not invoked again — AppendLiveEntity doesn't drive ref counts. + // Adapter not invoked again — projection placement doesn't drive ref counts. Assert.Single(captured.IncrementCalls); Assert.Equal(0, state.PendingLiveEntityCount); } diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs index e5a2034e..c5aef6e4 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -20,7 +20,7 @@ namespace AcDream.Core.Tests.Streaming; /// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, /// RemoveLandblock, RemoveEntitiesFromLandblock), and that the /// pending-bucket merge in AddLandblock does NOT double-fire for live -/// entities that already had OnCreate at . +/// entities, whose logical activation belongs to LiveEntityRuntime. /// public sealed class GpuWorldStateActivatorTests { @@ -99,9 +99,9 @@ public sealed class GpuWorldStateActivatorTests } [Fact] - public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() + public void PendingLiveProjection_DoesNotFireLogicalActivator() { - // Live entity (ServerGuid!=0) arrives via AppendLiveEntity first — + // Live entity (ServerGuid!=0) arrives via spatial projection first — // OnCreate fires once at that point. Then AddLandblock for the // same canonical id pulls the pending entity into the loaded list. // The new fire-site MUST NOT call OnCreate again (the live entity @@ -109,13 +109,12 @@ public sealed class GpuWorldStateActivatorTests var p = BuildPipeline(scriptId: 0xAAu); var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); - p.State.AppendLiveEntity(0xA9B4FFFFu, live); + p.State.PlaceLiveEntityProjection(0xA9B4FFFFu, live); var emptyLb = MakeStubLandblock(0xA9B4FFFFu); p.State.AddLandblock(emptyLb); p.Runner.Tick(0.001f); - Assert.Single(p.Recording.Calls); // exactly one — no double-fire - Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); + Assert.Empty(p.Recording.Calls); } [Fact] diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs index 0bfe8a30..bcb02948 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs @@ -29,7 +29,7 @@ public class GpuWorldStateTests }; [Fact] - public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately() + public void PlaceLiveEntityProjection_LandblockAlreadyLoaded_AppendsImmediately() { var state = new GpuWorldState(); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); @@ -37,20 +37,20 @@ public class GpuWorldStateTests // Server sends a spawn at landblock 0xA9B40011 — same landblock, // cell index 0x0011. Canonicalize drops the cell index, lookup // succeeds, entity lands in the loaded landblock immediately. - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42)); Assert.Single(state.Entities); Assert.Equal(0u, (uint)state.PendingLiveEntityCount); } [Fact] - public void AppendLiveEntity_LandblockNotLoaded_ParksInPending() + public void PlaceLiveEntityProjection_LandblockNotLoaded_ParksInPending() { var state = new GpuWorldState(); // No landblock loaded — the spawn must survive instead of being // silently dropped (the bug from Phase A.1's first live run). - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42)); Assert.Empty(state.Entities); // not visible yet Assert.Equal(1, state.PendingLiveEntityCount); @@ -62,9 +62,9 @@ public class GpuWorldStateTests var state = new GpuWorldState(); // Three spawns arrive before the landblock loads. - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); - state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell - state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); + state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell + state.PlaceLiveEntityProjection(0xA9B40033u, MakeStubEntity(3)); Assert.Equal(3, state.PendingLiveEntityCount); Assert.Empty(state.Entities); @@ -82,8 +82,8 @@ public class GpuWorldStateTests { var state = new GpuWorldState(); - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF - state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF + state.PlaceLiveEntityProjection(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF // Loading 0xA9B4FFFF only drains its own bucket. state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); @@ -93,12 +93,12 @@ public class GpuWorldStateTests } [Fact] - public void RelocateEntity_StrandedInPending_MovesToLoadedTarget() + public void RebucketLiveEntity_StrandedInPending_MovesToLoadedTarget() { // Regression: the cold-spawn / run-out "invisible player" bug // (2026-07-03). A persistent (server-spawned) entity that spawned into a // not-yet-loaded landblock is parked in the pending bucket. The per-frame - // RelocateEntity is supposed to keep it homed to its current landblock so + // RebucketLiveEntity keeps it homed to its current landblock so // it draws — but the old implementation scanned ONLY _loaded, so it // silently no-op'd on a pending entity and the player stayed hidden // forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)" @@ -118,16 +118,16 @@ public class GpuWorldStateTests state.MarkPersistent(0x5000000Au); // Spawned before its landblock streamed in → parked in pending, hidden. - state.AppendLiveEntity(0xADAF0011u, player); + state.PlaceLiveEntityProjection(0xADAF0011u, player); Assert.Empty(state.Entities); Assert.Equal(1, state.PendingLiveEntityCount); // The player's current landblock IS loaded now (the live log shows the - // sibling NPCs re-hydrated into it). RelocateEntity — called every frame + // sibling NPCs re-hydrated into it). RebucketLiveEntity — called every frame // to keep the player homed to its current landblock — must promote the // stranded pending entity into the loaded bucket so it draws. state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu)); - state.RelocateEntity(player, 0xBBBB0011u); + state.RebucketLiveEntity(player, 0xBBBB0011u); Assert.Single(state.Entities); // now drawn Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded @@ -139,8 +139,8 @@ public class GpuWorldStateTests var state = new GpuWorldState(); // Spawns for landblock 0xA9B4FFFF arrive while it's pending. - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); - state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); + state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); Assert.Equal(2, state.PendingLiveEntityCount); // Player moves away — the streamer says "this landblock is no @@ -159,7 +159,7 @@ public class GpuWorldStateTests var state = new GpuWorldState(); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); Assert.Single(state.Entities); state.RemoveLandblock(0xA9B4FFFFu); @@ -172,7 +172,7 @@ public class GpuWorldStateTests { var state = new GpuWorldState(); - state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); + state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); @@ -193,7 +193,7 @@ public class GpuWorldStateTests [Fact] public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket() { - // #138 (secondary): the player is re-injected via AppendLiveEntity every + // #138 (secondary): the player is re-injected via rebucketing every // frame; right after a teleport its landblock hasn't streamed in, so it // lands in the pending bucket. If that landblock is then unloaded before // it finishes loading, the persistent entity must still be rescued — @@ -203,7 +203,7 @@ public class GpuWorldStateTests state.MarkPersistent(playerGuid); var player = MakeServerEntity(id: 1, serverGuid: playerGuid); - state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending + state.PlaceLiveEntityProjection(0xA9B40011u, player); // landblock not loaded → pending Assert.Equal(1, state.PendingLiveEntityCount); state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending @@ -212,17 +212,36 @@ public class GpuWorldStateTests } [Fact] - public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity() + public void RemoveLandblock_RetainsNonPersistentLiveProjectionForSameIdentityReload() { - // A normal server object (door) in the pending bucket is NOT persistent; - // it must still be dropped (and is recoverable via #138 re-hydrate from - // the retained inbound spawn snapshot, not via the rescue path). + // A normal server object (door) is not rescued with the moving player, + // but its exact projection remains pending for this landblock. Reload + // must merge that identity instead of reconstructing from CreateObject. var state = new GpuWorldState(); var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent - state.AppendLiveEntity(0xA9B40011u, door); + state.PlaceLiveEntityProjection(0xA9B40011u, door); state.RemoveLandblock(0xA9B4FFFFu); Assert.Empty(state.DrainRescued()); + Assert.Equal(1, state.PendingLiveEntityCount); + state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); + Assert.Same(door, Assert.Single(state.Entities)); + } + + [Fact] + public void RemoveLandblock_LoadedLiveProjectionMovesToPendingAndReloadsSameIdentity() + { + var state = new GpuWorldState(); + var door = MakeServerEntity(id: 3, serverGuid: 0x7A9B4002u); + state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); + state.PlaceLiveEntityProjection(0xA9B40011u, door); + + state.RemoveLandblock(0xA9B4FFFFu); + + Assert.Empty(state.Entities); + Assert.Equal(1, state.PendingLiveEntityCount); + state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); + Assert.Same(door, Assert.Single(state.Entities)); } } diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs index 3f94a5aa..e75a3321 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs @@ -37,6 +37,32 @@ public class GpuWorldStateTwoTierTests Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident } + [Fact] + public void RemoveEntitiesFromLandblock_DropsStaticLayerButRetainsLiveProjection() + { + var state = new GpuWorldState(); + WorldEntity staticEntity = MakeStubEntity(1); + var liveEntity = new WorldEntity + { + Id = 2, + ServerGuid = 0x70000002u, + SourceGfxObjOrSetupId = 0x01000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + state.AddLandblock(MakeStubLandblock( + 0xAAAAFFFFu, + staticEntity, + liveEntity)); + + state.RemoveEntitiesFromLandblock(0xAAAAFFFFu); + + Assert.Same(liveEntity, Assert.Single(state.Entities)); + Assert.DoesNotContain(staticEntity, state.Entities); + Assert.True(state.IsLoaded(0xAAAAFFFFu)); + } + [Fact] public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord() { @@ -118,7 +144,6 @@ public class GpuWorldStateTwoTierTests int callCount = 0; var state = new GpuWorldState( wbSpawnAdapter: null, - wbEntitySpawnAdapter: null, onLandblockUnloaded: id => { observed = id; callCount++; }); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1))); @@ -144,7 +169,6 @@ public class GpuWorldStateTwoTierTests int callCount = 0; var state = new GpuWorldState( wbSpawnAdapter: null, - wbEntitySpawnAdapter: null, onLandblockUnloaded: _ => callCount++); // Landblock never loaded. diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs deleted file mode 100644 index db7bc597..00000000 --- a/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.Streaming; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -/// -/// #138 — selection logic for re-hydrating server objects when a landblock -/// reloads after a dungeon collapse (or a Near→Far demote). See -/// for the retail/ACE rationale. -/// -public class LandblockEntityRehydratorTests -{ - private const uint PlayerGuid = 0x50000001u; - // A door spawned in cell 0x00070123 of dungeon landblock 0x0007; its - // cell-resolved spawn id must match the streamed canonical id 0x0007FFFF. - private const uint DungeonLb = 0x0007FFFFu; - private const uint DoorGuid = 0x7A9B4001u; - private const uint DoorSpawnId = 0x00070123u; - - private static LandblockEntityRehydrator.RetainedSpawn Spawn( - uint guid, uint spawnLbId, bool hasMesh = true) - => new(guid, spawnLbId, hasMesh); - - [Fact] - public void RetainedSpawnInLoadedLandblock_NotPresent_IsSelected() - { - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId) }, - new HashSet(), - PlayerGuid); - - Assert.Equal(new[] { DoorGuid }, result); - } - - [Fact] - public void CellResolvedSpawnId_MatchesCanonicalLoadedId() - { - // The streamed landblock id is canonical (0xAAAAFFFF); the spawn id is - // cell-resolved (0xAAAA00CC). They name the same landblock, so the door - // must be selected — the canonicalization is the load-bearing step. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, // 0x0007FFFF - new[] { Spawn(DoorGuid, 0x000701A9u) }, // a different cell, same landblock - new HashSet(), - PlayerGuid); - - Assert.Equal(new[] { DoorGuid }, result); - } - - [Fact] - public void SpawnInDifferentLandblock_IsNotSelected() - { - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, 0xA9B30123u) }, // Holtburg, not the loaded dungeon - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void AlreadyPresentGuid_IsNotSelected() - { - // Already rendered (server re-sent it, or it was never dropped) — a - // re-hydrate would be a redundant mesh rebuild. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId) }, - new HashSet { DoorGuid }, - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void PlayerGuid_IsNeverSelected() - { - // The player has a retained spawn too, but the persistent-rescue path - // owns it (preserves its live pose). Re-hydrating would reset it. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(PlayerGuid, DoorSpawnId) }, - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void SpawnWithoutWorldMesh_IsNotSelected() - { - // Inventory items / setup-less spawns build no visible entity. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId, hasMesh: false) }, - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void MixedSet_SelectsOnlyAbsentWorldObjectsInLoadedLandblock() - { - uint npcGuid = 0x7A9B4002u; // same dungeon landblock, absent → select - uint chestGuid = 0x7A9B4003u; // same landblock but already present → skip - uint holtburgGuid = 0x7A9B4004u; // different landblock → skip - uint heldItemGuid = 0x7A9B4005u; // no world mesh → skip - - var spawns = new[] - { - Spawn(DoorGuid, DoorSpawnId), // select - Spawn(npcGuid, 0x00070177u), // select (same lb, different cell) - Spawn(chestGuid, 0x00070123u), // skip (present) - Spawn(holtburgGuid, 0xA9B30100u), // skip (other lb) - Spawn(heldItemGuid, 0x00070123u, hasMesh: false), // skip (no mesh) - Spawn(PlayerGuid, 0x00070100u), // skip (player) - }; - - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, spawns, new HashSet { chestGuid }, PlayerGuid); - - Assert.Equal(new HashSet { DoorGuid, npcGuid }, new HashSet(result)); - } -} diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index 20f45cf2..b7914ccc 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -78,19 +78,20 @@ public class StreamingControllerTests // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg // name differs. - var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); + const uint landblockId = 0x3232FFFFu; + var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty()); // A.5 T10-T12 follow-up: use a real empty mesh instance instead of // default! so any future test that flows MeshData through the apply // callback gets a non-null reference to inspect rather than an NRE. var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); - fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh)); + fake.Pending.Enqueue(new LandblockStreamResult.Loaded(landblockId, LandblockStreamTier.Near, lb, stubMesh)); controller.Tick(50, 50); Assert.Single(applied); - Assert.True(state.IsLoaded(0x32320FFEu)); + Assert.True(state.IsLoaded(landblockId)); } [Fact] @@ -102,12 +103,13 @@ public class StreamingControllerTests fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, (_, _) => { }, state, nearRadius: 2, farRadius: 2); - var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); + const uint landblockId = 0x3232FFFFu; + var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb); - fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(0x32320FFEu)); + fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId)); controller.Tick(50, 50); - Assert.False(state.IsLoaded(0x32320FFEu)); + Assert.False(state.IsLoaded(landblockId)); } }