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 <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 07:47:03 +02:00
parent 8a5d77f7f4
commit 8dd996053d
24 changed files with 2449 additions and 631 deletions

View file

@ -255,14 +255,21 @@ Ownership by phase:
## GameEntity: The Unified Entity (target refactor) ## GameEntity: The Unified Entity (target refactor)
Currently, entity state is scattered across: `LiveEntityRuntime` is now the shipped bridge to this target. It owns one
- `WorldEntity` (position, rotation, mesh refs) `LiveEntityRecord` per accepted server-object incarnation, ServerGuid-to-local-id
- `AnimatedEntity` (animation frame, setup, sequencer) translation, accepted snapshots/timestamp gates, animation and remote-motion
- `_entitiesByServerGuid` dict (server GUID lookup) components, parent-event state, and exact logical teardown. `GpuWorldState`
- `GpuWorldState._loaded[lb].Entities` (per-landblock lists) owns spatial buckets only: register/rebucket/unregister are separate operations,
- `_playerController` (player-specific movement) 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 ```csharp
public sealed class GameEntity public sealed class GameEntity

View file

@ -188,8 +188,9 @@ src/AcDream.App/
├── Net/ ├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect │ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
├── World/ ├── World/
│ ├── InboundPhysicsStateController.cs # shipped: timestamps + accepted spawn snapshots │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
│ └── LiveEntityRuntime.cs # target: full lifecycle + ServerGuid↔entity.Id translation │ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
├── Interaction/ ├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch │ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion ├── 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 Until then, the parallel-dicts problem is bounded inside one class
instead of spread across `GameWindow`. instead of spread across `GameWindow`.
`InboundPhysicsStateController` is the first shipped slice of that boundary: `LiveEntityRuntime` is now that single boundary. It composes
it owns the nine-channel retail timestamp gates and the latest accepted `InboundPhysicsStateController` for the nine-channel retail timestamp gates and
immutable CreateObject snapshot. It deliberately owns no renderer adapter, latest accepted immutable CreateObject snapshot, owns the canonical local ID
local entity ID, physics body, animation, effect, or spatial bucket; those and optional runtime components, and separates logical registration from
move together in the `LiveEntityRuntime` extraction. spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved
relations by child and parent generation. A future general mixed packet queue
`ParentAttachmentState` is a focused interim pending queue for ParentEvent: still has to cover the non-Parent packet families tracked by divergence AD-32.
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.
--- ---
@ -280,22 +278,36 @@ constructed without a live socket (offline mode).
**Verification:** Visual login + Holtburg traversal + door interaction **Verification:** Visual login + Holtburg traversal + door interaction
identical to pre-extraction. identical to pre-extraction.
### Step 3 — `LiveEntityRuntime` (or `EntityRuntimeRegistry`) ### Step 3 — `LiveEntityRuntime` — SHIPPED 2026-07-14
**Scope:** Centralize the parallel dictionaries — `_entitiesByServerGuid`, **Shipped scope:** One `LiveEntityRecord` per server-object incarnation now owns
the streaming entity lists, the player controller's player entity — ServerGuid↔local-ID translation, accepted state, runtime components, parent
into one owner. Surface the ServerGuid↔entity.Id translation as a relations, logical resource activation, exact teardown, and spatial projection
single API (eliminating the trap from L.2g slice 1c). 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 **Risk:** Medium-high. Entity lookup is in every hot path. The change
is structural (one owner instead of three) but the lookup semantics is structural (one owner instead of three) but the lookup semantics
must be byte-identical. must be byte-identical.
**Test:** Entity-spawn / despawn / lookup tests in **Test:** `LiveEntityRuntimeTests` cover duplicate CreateObject, generation
`tests/AcDream.App.Tests/`. Existing visual verification at Holtburg replacement, appearance mutation, loaded/pending rebucketing, attached
catches any drift in interaction. 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. **Verification:** Walk Holtburg, click NPC, open door, pick up item.
All four M1 demo targets must still work. All four M1 demo targets must still work.

View file

@ -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-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-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-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-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<T>`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-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`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) | | 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-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-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-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-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-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:309573309597 | | 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:309573309597 |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-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 |

View file

@ -500,6 +500,7 @@ behavior. Estimated 1726 days focused work, 35 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 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`. - **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`. - **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 02 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 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. - **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`. - **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`.

View file

@ -471,6 +471,15 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 02
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)** - **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 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 funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -36,7 +36,7 @@ public static class DollEntityBuilder
/// <summary> /// <summary>
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton) /// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The /// and high enough never to collide with LiveEntityRuntime's local ids. The
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the /// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
/// doll as animated — which BYPASSES the Tier-1 classification cache /// doll as animated — which BYPASSES the Tier-1 classification cache
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW /// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW

View file

@ -1,5 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Streaming; using AcDream.App.World;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Net; using AcDream.Core.Net;
@ -24,13 +24,10 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly DatCollection _dats; private readonly DatCollection _dats;
private readonly object _datLock; private readonly object _datLock;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly GpuWorldState _worldState; private readonly LiveEntityRuntime _liveEntities;
private readonly Func<uint, WorldEntity?> _resolveEntity;
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
private readonly Func<ParentEvent.Parsed, bool> _acceptParent; private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private readonly Func<uint> _nextEntityId;
private readonly ParentAttachmentState _relations = new(); private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new(); private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
public IEnumerable<uint> AttachedEntityIds public IEnumerable<uint> AttachedEntityIds
@ -46,20 +43,14 @@ public sealed class EquippedChildRenderController : IDisposable
DatCollection dats, DatCollection dats,
object datLock, object datLock,
ClientObjectTable objects, ClientObjectTable objects,
GpuWorldState worldState, LiveEntityRuntime liveEntities,
Func<uint, WorldEntity?> resolveEntity, Func<ParentEvent.Parsed, bool> acceptParent)
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
Func<ParentEvent.Parsed, bool> acceptParent,
Func<uint> nextEntityId)
{ {
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack; _objects.MoveRolledBack += OnMoveRolledBack;
@ -79,7 +70,7 @@ public sealed class EquippedChildRenderController : IDisposable
&& spawn.ParentLocation is { } parentLocation && spawn.ParentLocation is { } parentLocation
&& spawn.PlacementId is { } placementId) && spawn.PlacementId is { } placementId)
{ {
_relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid, parentGuid,
spawn.Guid, spawn.Guid,
parentLocation, parentLocation,
@ -93,7 +84,7 @@ public sealed class EquippedChildRenderController : IDisposable
// ParentEvent can precede the parent's CreateObject. Revisit every // ParentEvent can precede the parent's CreateObject. Revisit every
// child waiting specifically on the object that just arrived. // child waiting specifically on the object that just arrived.
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(spawn.Guid); IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(spawn.Guid);
for (int i = 0; i < waiting.Count; i++) for (int i = 0; i < waiting.Count; i++)
{ {
ResolveRelations(waiting[i]); ResolveRelations(waiting[i]);
@ -112,7 +103,7 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
lock (_datLock) lock (_datLock)
{ {
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(guid); IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(guid);
for (int i = 0; i < waiting.Count; i++) for (int i = 0; i < waiting.Count; i++)
{ {
ResolveRelations(waiting[i]); ResolveRelations(waiting[i]);
@ -130,7 +121,7 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
lock (_datLock) lock (_datLock)
{ {
_relations.Enqueue(update); Relations.Enqueue(update);
ResolveRelations(update.ChildGuid); ResolveRelations(update.ChildGuid);
TryRealize(update.ChildGuid); TryRealize(update.ChildGuid);
} }
@ -139,13 +130,11 @@ public sealed class EquippedChildRenderController : IDisposable
public void OnGenerationReplaced(uint guid, ushort replacementGeneration) public void OnGenerationReplaced(uint guid, ushort replacementGeneration)
{ {
TearDownObjectProjections(guid); TearDownObjectProjections(guid);
_relations.EndGeneration(guid, replacementGeneration);
} }
public void OnGenerationDeleted(uint guid, ushort deletedGeneration) public void OnGenerationDeleted(uint guid, ushort deletedGeneration)
{ {
TearDownObjectProjections(guid); TearDownObjectProjections(guid);
_relations.DeleteGeneration(guid, deletedGeneration);
} }
private void OnObjectRemovalClassified(ClientObjectRemoval removal) private void OnObjectRemovalClassified(ClientObjectRemoval removal)
@ -160,7 +149,7 @@ public sealed class EquippedChildRenderController : IDisposable
// same-generation world/parent update can still replay them. Logical // same-generation world/parent update can still replay them. Logical
// delete/replacement are driven directly by the accepted inbound // delete/replacement are driven directly by the accepted inbound
// lifecycle and never depend on this table. // lifecycle and never depend on this table.
_relations.EndChildProjection(guid); Relations.EndChildProjection(guid);
} }
/// <summary> /// <summary>
@ -172,7 +161,7 @@ public sealed class EquippedChildRenderController : IDisposable
public void OnChildBecameUnparented(uint childGuid) public void OnChildBecameUnparented(uint childGuid)
{ {
Remove(childGuid); Remove(childGuid);
_relations.EndChildProjection(childGuid); Relations.EndChildProjection(childGuid);
} }
/// <summary>Recompose every child after the parent's animation tick.</summary> /// <summary>Recompose every child after the parent's animation tick.</summary>
@ -180,8 +169,7 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
foreach (AttachedChild child in _attachedByChild.Values) foreach (AttachedChild child in _attachedByChild.Values)
{ {
WorldEntity? parent = _resolveEntity(child.ParentGuid); if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
if (parent is null)
continue; continue;
if (!EquippedChildAttachment.TryCompose( if (!EquippedChildAttachment.TryCompose(
@ -199,22 +187,23 @@ public sealed class EquippedChildRenderController : IDisposable
child.Entity.SetPosition(parent.Position); child.Entity.SetPosition(parent.Position);
child.Entity.Rotation = parent.Rotation; child.Entity.Rotation = parent.Rotation;
child.Entity.ParentCellId = parent.ParentCellId; child.Entity.ParentCellId = parent.ParentCellId;
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
} }
} }
private void TryRealize(uint childGuid) private void TryRealize(uint childGuid)
{ {
if (!_relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending)) if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
return; return;
WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid); if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid); || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid); || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
if (parentEntity is null || parentSpawn is null || childSpawn is null)
return; return;
if (parentSpawn.Value.SetupTableId is not { } parentSetupId if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.Value.SetupTableId is not { } childSetupId || childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId) || parentEntity.ParentCellId is not { } parentCellId)
return; return;
@ -225,8 +214,8 @@ public sealed class EquippedChildRenderController : IDisposable
var parentLocation = (ParentLocation)pending.ParentLocation; var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId; var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn.Value); IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn);
float scale = childSpawn.Value.ObjScale is { } objScale && objScale > 0f float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale ? objScale
: 1.0f; : 1.0f;
@ -242,19 +231,32 @@ public sealed class EquippedChildRenderController : IDisposable
return; return;
Remove(childGuid); Remove(childGuid);
var entity = new WorldEntity WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
parentCellId,
localId => new WorldEntity
{ {
Id = _nextEntityId(), Id = localId,
ServerGuid = childGuid, ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId, SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position, Position = parentEntity.Position,
Rotation = parentEntity.Rotation, Rotation = parentEntity.Rotation,
MeshRefs = parts, MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn.Value), PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId, ParentCellId = parentCellId,
}; },
LiveEntityProjectionKind.Attached);
_worldState.AppendLiveEntity(parentCellId, entity); 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<PartOverride>());
_attachedByChild[childGuid] = new AttachedChild( _attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid, pending.ParentGuid,
childGuid, childGuid,
@ -268,15 +270,17 @@ public sealed class EquippedChildRenderController : IDisposable
Console.WriteLine( Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}"); $"location={parentLocation} placement={placement}");
_relations.MarkProjected(childGuid); Relations.MarkProjected(childGuid);
} }
private void ResolveRelations(uint childGuid) private void ResolveRelations(uint childGuid)
{ {
_relations.Resolve( Relations.Resolve(
childGuid, childGuid,
guid => _resolveSpawn(guid) is not null, guid => _liveEntities.TryGetSnapshot(guid, out _),
guid => _resolveSpawn(guid)?.InstanceSequence, guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null,
_acceptParent); _acceptParent);
} }
@ -370,7 +374,7 @@ public sealed class EquippedChildRenderController : IDisposable
// A rejected unwield restores the equipped location without a fresh // A rejected unwield restores the equipped location without a fresh
// wire ParentEvent; reinstall the last accepted relationship only for // wire ParentEvent; reinstall the last accepted relationship only for
// this explicit rollback signal. // this explicit rollback signal.
if (_relations.RestoreLastAccepted(item.ObjectId)) if (Relations.RestoreLastAccepted(item.ObjectId))
{ {
lock (_datLock) lock (_datLock)
TryRealize(item.ObjectId); TryRealize(item.ObjectId);
@ -381,7 +385,7 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
if (!_attachedByChild.Remove(childGuid)) if (!_attachedByChild.Remove(childGuid))
return; return;
_worldState.RemoveEntityByServerGuid(childGuid); _liveEntities.WithdrawLiveEntityProjection(childGuid);
} }
private void TearDownObjectProjections(uint guid) private void TearDownObjectProjections(uint guid)
@ -401,7 +405,7 @@ public sealed class EquippedChildRenderController : IDisposable
uint[] attached = _attachedByChild.Keys.ToArray(); uint[] attached = _attachedByChild.Keys.ToArray();
for (int i = 0; i < attached.Length; i++) for (int i = 0; i < attached.Length; i++)
Remove(attached[i]); Remove(attached[i]);
_relations.Clear(); Relations.Clear();
} }
public void Dispose() public void Dispose()

View file

@ -1,4 +1,5 @@
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.App.World;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using Silk.NET.Input; using Silk.NET.Input;
@ -245,7 +246,7 @@ public sealed class GameWindow : IDisposable
/// Static decorations and entities with no motion table never /// Static decorations and entities with no motion table never
/// appear in this map. /// appear in this map.
/// </summary> /// </summary>
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new(); private readonly LiveEntityAnimationRuntimeView<AnimatedEntity> _animatedEntities;
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically // 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 // #184 Slice 2a: internal (was private) so the extracted
// RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches
// RemoteMotion's existing internal visibility. // RemoteMotion's existing internal visibility.
internal sealed class AnimatedEntity internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime
{ {
public required AcDream.Core.World.WorldEntity Entity; 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.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation; public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame; public required int LowFrame;
@ -412,7 +414,7 @@ public sealed class GameWindow : IDisposable
/// motion tables with HasVelocity=0). /// motion tables with HasVelocity=0).
/// </para> /// </para>
/// </summary> /// </summary>
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new(); private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
/// <summary> /// <summary>
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
@ -422,7 +424,7 @@ public sealed class GameWindow : IDisposable
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in /// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
/// <see cref="OnLiveEntityDeleted"/>. /// <see cref="OnLiveEntityDeleted"/>.
/// </summary> /// </summary>
private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new(); private AcDream.App.World.LiveEntityRuntime? _liveEntities;
/// <summary> /// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of /// 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. /// remote gets the same treatment as the local player.
/// </para> /// </para>
/// </summary> /// </summary>
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; public AcDream.Core.Physics.PhysicsBody Body;
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the /// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h /// 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- // closes (stale-centered geometry built during the InWorld-but-not-yet-
// located window, applied late once its build finished). // located window, applied late once its build finished).
private bool _liveCenterKnown; 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 // K-fix1 (2026-04-26): cached at startup so per-frame branches are
// single-flag reads instead of env-var lookups. True iff // single-flag reads instead of env-var lookups. True iff
@ -1016,10 +1018,19 @@ public sealed class GameWindow : IDisposable
/// <summary> /// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the /// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. The sequential <see cref="_liveEntityIdCounter"/> /// server is talking about. This is the canonical materialized top-level
/// keys the render list; this parallel dictionary keys by server guid. /// projection, including live objects parked in pending landblocks;
/// visibility must never suppress authoritative mutation.
/// </summary> /// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new(); private static readonly IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> EmptyLiveEntityMap =
new Dictionary<uint, AcDream.Core.World.WorldEntity>();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> EmptyLiveSpawnMap =
new Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn>();
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid =>
_liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap;
/// <summary>Visible-only view for radar, picking, status, and targets.</summary>
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities?.WorldEntities ?? EmptyLiveEntityMap;
/// <summary> /// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each /// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured before the renderability gate so no-position inventory / /// 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. /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary> /// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns => private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_inboundPhysics.Snapshots; _liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// B.6/B.7 (2026-05-16): pending close-range action that will be fired // 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 // once the local auto-walk overlay reports arrival (body has finished
// rotating to face the target). Only set for close-range Use/PickUp; // 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, // CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
// giving the TargetManager voyeur round-trip its cross-entity delivery // giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
// (player); pruned in RemoveLiveEntityByServerGuid. // (player); pruned only by logical LiveEntityRuntime teardown.
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new(); private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
@ -1078,6 +1089,8 @@ public sealed class GameWindow : IDisposable
_worldEvents = worldEvents; _worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's // #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 // around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame: // live state from this GameWindow instance every frame:
// - selected guid → shared SelectionState // - selected guid → shared SelectionState
// - entity resolver → position from _entitiesByServerGuid + // - entity resolver → position from the visible world view +
// itemType from ClientObjectTable (Objects) + last spawn // itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not // - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0. // yet ready, in which case the panel bails on viewport==0.
@ -1519,7 +1532,7 @@ public sealed class GameWindow : IDisposable
selectedGuidProvider: () => _selection.SelectedObjectId, selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid => entityResolver: guid =>
{ {
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity))
return null; return null;
uint rawItemType = (uint)LiveItemType(guid); uint rawItemType = (uint)LiveItemType(guid);
uint pwdBits = 0; uint pwdBits = 0;
@ -2098,7 +2111,7 @@ public sealed class GameWindow : IDisposable
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider( var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects, Objects,
_entitiesByServerGuid, _visibleEntitiesByServerGuid,
LastSpawns, LastSpawns,
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f, playerYawRadians: () => _playerController?.Yaw ?? 0f,
@ -2304,21 +2317,36 @@ public sealed class GameWindow : IDisposable
// matching the LandblockHint stored at Populate time. // matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState( _worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter, wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock, onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator); 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( _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!, _dats!,
_datLock, _datLock,
Objects, Objects,
_worldState, _liveEntities,
guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null, TryAcceptParentForRender);
guid => LastSpawns.TryGetValue(guid, out var spawn)
? spawn
: (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
TryAcceptParentForRender,
() => _liveEntityIdCounter++);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
@ -2537,7 +2565,16 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer?.Clear(); _equippedChildRenderer?.Clear();
Objects.Clear(); Objects.Clear();
_selection.Reset(); _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();
}
} }
/// <summary> /// <summary>
@ -2933,10 +2970,14 @@ public sealed class GameWindow : IDisposable
// with BuildLandblockForStreaming on the worker thread. // with BuildLandblockForStreaming on the worker thread.
lock (_datLock) 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) if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
return; return;
try
{
PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration) if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration)
@ -2960,6 +3001,19 @@ public sealed class GameWindow : IDisposable
OnLiveEntitySpawnedLocked(result.Snapshot); 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);
}
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);
}
} }
/// <summary> /// <summary>
@ -2969,8 +3023,8 @@ public sealed class GameWindow : IDisposable
/// <c>AddLandblock</c> / <c>AddEntitiesToExistingLandblock</c>. /// <c>AddLandblock</c> / <c>AddEntitiesToExistingLandblock</c>.
/// ///
/// <para> /// <para>
/// The dungeon collapse (and Near→Far demote) drops a landblock's render /// A full landblock unload drops its dat-static render layer and parks live
/// entities for FPS but keeps the parsed spawns in <see cref="LastSpawns"/> /// projections pending, while keeping the parsed spawns in <see cref="LastSpawns"/>
/// (our <c>weenie_object_table</c> for world objects). ACE never /// (our <c>weenie_object_table</c> for world objects). ACE never
/// re-broadcasts objects it believes we still know — its per-player /// re-broadcasts objects it believes we still know — its per-player
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified /// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
@ -2981,55 +3035,53 @@ public sealed class GameWindow : IDisposable
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Idempotent and cheap when nothing is missing: the selection skips guids /// Idempotent and cheap when nothing is missing. A materialized record is
/// already present in <see cref="_worldState"/> (initial login, or objects /// rebucketed with the same identity; a record whose Setup was unavailable
/// ACE did re-send), the player (owned by the persistent-entity rescue /// on first arrival gets one first-materialization attempt. Neither path
/// path), and spawns with no world mesh. The replay's own /// replays logical renderer or script registration.
/// <see cref="RemoveLiveEntityByServerGuid"/> de-dup scrubs the state the
/// collapse orphaned — the entity lingers in <see cref="_entitiesByServerGuid"/>
/// after <c>RemoveLandblock</c> even though its render entity is gone.
/// </para> /// </para>
/// </summary> /// </summary>
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId) 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 uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which LiveEntityRecord[] records = _liveEntities.Records
// still holds collapse-orphaned entries whose render entity is gone. .Where(record => record.ServerGuid != _playerServerGuid
var present = new HashSet<uint>(); && record.Snapshot.Position is { } position
foreach (var e in _worldState.Entities) && ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical
if (e.ServerGuid != 0) present.Add(e.ServerGuid); && record.Snapshot.SetupTableId is not null)
.ToArray();
if (records.Length == 0) return;
// Snapshot retained spawns before render projection changes. int projected = 0;
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
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.
lock (_datLock) lock (_datLock)
{ {
foreach (var guid in guids) foreach (LiveEntityRecord record in records)
if (LastSpawns.TryGetValue(guid, out var spawn)) {
OnLiveEntitySpawnedLocked(spawn); // 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( 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}");
} }
/// <summary> /// <summary>
@ -3051,19 +3103,10 @@ public sealed class GameWindow : IDisposable
{ {
_liveSpawnReceived++; _liveSpawnReceived++;
// De-dup: the server re-sends CreateObject for the same guid in // LiveEntityRuntime has already classified this as a first
// several situations (visibility refresh, landblock crossing, // materialization, a spatial re-entry of the same incarnation, or an
// appearance update). Without cleanup the OLD copy remains in // appearance mutation. This method never de-duplicates by destroying a
// GpuWorldState + WorldGameState + _animatedEntities, so the // still-live projection and never reconstructs from stale spawn data.
// 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);
// Retail's weenie-object table retains CreateObject data for inventory // Retail's weenie-object table retains CreateObject data for inventory
// and parented children even when they have no world Position. Held // 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 // it above can synchronously advance the canonical child POSITION_TS
// and turn this object into an attachment. Select the projection from // and turn this object into an attachment. Select the projection from
// that post-callback snapshot, never from the stale method argument. // 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; spawn = canonicalSpawn;
// When requested, log every spawn that arrives so we can inventory what the server // When requested, log every spawn that arrives so we can inventory what the server
@ -3634,12 +3677,27 @@ public sealed class GameWindow : IDisposable
if (visualUpdate.Animation is { } animation) if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs); RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
_classificationCache.InvalidateEntity(existing.Id); _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; return;
} }
var entity = new AcDream.Core.World.WorldEntity bool createdProjection = false;
var entity = _liveEntities!.MaterializeLiveEntity(
spawn.Guid,
spawn.Position!.Value.LandblockId,
localId =>
{ {
Id = _liveEntityIdCounter++, createdProjection = true;
var created = new AcDream.Core.World.WorldEntity
{
Id = localId,
ServerGuid = spawn.Guid, ServerGuid = spawn.Guid,
SourceGfxObjOrSetupId = spawn.SetupTableId.Value, SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
Position = worldPos, Position = worldPos,
@ -3647,10 +3705,33 @@ public sealed class GameWindow : IDisposable
MeshRefs = meshRefs, MeshRefs = meshRefs,
PaletteOverride = paletteOverride, PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides, PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position!.Value.LandblockId, ParentCellId = spawn.Position.Value.LandblockId,
}; };
if (liveBounds.TryGet(out var liveBMin, out var liveBMax)) if (liveBounds.TryGet(out var createdMin, out var createdMax))
entity.SetLocalBounds(liveBMin, liveBMax); created.SetLocalBounds(createdMin, createdMax);
return created;
});
if (entity is null)
return;
if (!createdProjection)
{
// 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, // A7 indoor lighting: server-spawned weenie fixtures (lanterns,
// braziers, glowing items) carry their light in Setup.Lights exactly // 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 // ApplyLoadedTerrainLocked never sees CreateObject entities — so an
// interior's lanterns cast no light and the room reads dark. Register // interior's lanterns cast no light and the room reads dark. Register
// them here, mirroring that path (GameWindow.cs ~6742). Owned by // them here, mirroring that path (GameWindow.cs ~6742). Owned by
// entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears // entity.Id, so leave-world and logical teardown both remove their
// them down on despawn/respawn. Retail registers object-borne lights // cell-scoped presentation. Retail registers object-borne lights
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0). // regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
// The light is placed at the spawn frame and does NOT follow a moving // 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, // light-bearer yet (register row AP-44) — fine for stationary fixtures,
@ -3699,17 +3780,18 @@ public sealed class GameWindow : IDisposable
Position: entity.Position, Position: entity.Position,
Rotation: entity.Rotation); Rotation: entity.Rotation);
_worldGameState.Add(snapshot); _worldGameState.Add(snapshot);
if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid))
_worldEvents.FireEntitySpawned(snapshot); _worldEvents.FireEntitySpawned(snapshot);
// Phase A.1: register entity into GpuWorldState so the next frame picks // 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 // it up. Materialization parks the projection when its landblock is
// (can happen if the server sends CreateObjects before we finish loading). // not loaded yet, then AddLandblock merges the same identity.
_worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity); // MaterializeLiveEntity above performed the spatial projection.
_liveSpawnHydrated++; _liveSpawnHydrated++;
// Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
// UpdateMotion / UpdatePosition events can reseat this entity by guid. // 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 // The root now exists, so parent relations that arrived before this
// object's render projection can compose their child meshes. // object's render projection can compose their child meshes.
@ -3738,6 +3820,8 @@ public sealed class GameWindow : IDisposable
// cycle (single-frame poses are static and don't need ticking). // cycle (single-frame poses are static and don't need ticking).
// Diagnostic: log why we did / didn't register so we can tell // Diagnostic: log why we did / didn't register so we can tell
// which entities fall through the filter. // which entities fall through the filter.
if (!retainedAnimationRuntime)
{
if (idleCycle is null) if (idleCycle is null)
_liveAnimRejectNoCycle++; _liveAnimRejectNoCycle++;
else if (idleCycle.Framerate == 0f) else if (idleCycle.Framerate == 0f)
@ -3746,8 +3830,10 @@ public sealed class GameWindow : IDisposable
_liveAnimRejectSingleFrame++; _liveAnimRejectSingleFrame++;
else if (idleCycle.Animation.PartFrames.Count <= 1) else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++; _liveAnimRejectPartFrames++;
}
if (idleCycle is not null && idleCycle.Framerate != 0f if (!retainedAnimationRuntime
&& idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame && idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1) && idleCycle.Animation.PartFrames.Count > 1)
{ {
@ -3799,7 +3885,7 @@ public sealed class GameWindow : IDisposable
_entitySoundTables.Set(entity.Id, soundTableId); _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 // Phase B.4c / #187 — reactive motion-table rescue. An entity
// whose REST pose is a static single frame fails the generic // 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) private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{ {
if (!_inboundPhysics.TryDelete( // 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, delete,
isLocalPlayer: delete.Guid == _playerServerGuid)) isLocalPlayer: delete.Guid == _playerServerGuid,
return; beforeTeardown: () =>
{
_equippedChildRenderer?.OnGenerationDeleted( _equippedChildRenderer?.OnGenerationDeleted(
delete.Guid, delete.Guid,
delete.InstanceSequence); delete.InstanceSequence);
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
});
bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
if (removed if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
@ -3941,7 +4033,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update) 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 // Server can broadcast ObjDescEvent before we've seen a
// CreateObject for this guid (race on landblock entry, or // CreateObject for this guid (race on landblock entry, or
@ -3971,7 +4063,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid) private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid)
{ {
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var entity)) if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true)
return null; return null;
_animatedEntities.TryGetValue(entity.Id, out var animation); _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 /// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body. /// the <see cref="ShadowObjectRegistry"/> as a single collision body.
/// One entry per entity (in contrast to static scenery's per-CylSphere /// One entry per entity (in contrast to static scenery's per-CylSphere
/// registration) so <c>RemoveLiveEntityByServerGuid</c>'s single /// registration) so the leave-world path's single
/// <c>Deregister(entity.Id)</c> cleans it up without leaks. /// <c>Deregister(entity.Id)</c> cleans it up without leaks.
/// ///
/// <para> /// <para>
@ -4272,9 +4364,9 @@ public sealed class GameWindow : IDisposable
OnLiveAppearanceUpdated(refresh.Appearance); OnLiveAppearanceUpdated(refresh.Appearance);
if (refresh.Parent is { } parent 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); _equippedChildRenderer?.OnSpawn(acceptedParent);
} }
else if (refresh.Position is { } position) else if (refresh.Position is { } position)
@ -4299,11 +4391,11 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
{ {
if (!_inboundPhysics.TryApplyPickup(pickup, out _)) if (!_liveEntities!.TryApplyPickup(pickup, out _))
return; return;
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid); _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid);
bool removed = RemoveLiveEntityByServerGuid(pickup.Guid); bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid);
if (removed if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") && 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) private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
{ {
if (!_inboundPhysics.TryApplyParent(update, out _)) return false; if (!_liveEntities!.TryApplyParent(update, out _)) return false;
RemoveLiveEntityByServerGuid(update.ChildGuid); WithdrawLiveEntityWorldProjection(update.ChildGuid);
return true; return true;
} }
@ -4671,13 +4763,22 @@ public sealed class GameWindow : IDisposable
host.PositionManager.StickTo(targetGuid, radius, height); host.PositionManager.StickTo(targetGuid, radius, height);
} }
private bool RemoveLiveEntityByServerGuid(uint serverGuid) private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{ {
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) // AD-32 transitional F754 ownership: before Step 4's mixed pending
return false; // 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); if (record.WorldEntity is not { } existingEntity)
_worldGameState.RemoveById(existingEntity.Id); return;
uint serverGuid = record.ServerGuid;
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
// independently (r3-port-plan §4): the manager's (each pending // independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops // animation fires MotionDone(success:false) → the bound interp pops
@ -4713,15 +4814,11 @@ public sealed class GameWindow : IDisposable
_physicsHosts.Remove(serverGuid); _physicsHosts.Remove(serverGuid);
_animatedEntities.Remove(existingEntity.Id); _animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(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 // Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the next spawn/update would use. // clear using the same guid the next spawn/update would use.
_remoteDeadReckon.Remove(serverGuid); _remoteDeadReckon.Remove(serverGuid);
_remoteLastMove.Remove(serverGuid); _remoteLastMove.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
if (_selection.SelectedObjectId == serverGuid) if (_selection.SelectedObjectId == serverGuid)
{ {
_selection.Clear( _selection.Clear(
@ -4729,15 +4826,47 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); 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 _translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record);
}
return true; /// <summary>
/// Retail <c>CPhysicsObj::leave_world</c> (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 <see cref="TearDownLiveEntityRuntimeComponents"/>.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
} }
/// <summary> /// <summary>
@ -4884,7 +5013,7 @@ public sealed class GameWindow : IDisposable
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
// straggler re-applies an old gait or un-stops a stop. // straggler re-applies an old gait or un-stops a stop.
bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous; bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
if (!_inboundPhysics.TryApplyMotion( if (!_liveEntities!.TryApplyMotion(
update, update,
retainPayload, retainPayload,
out _, out _,
@ -5374,7 +5503,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) 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; if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
@ -5444,7 +5573,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) 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; 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) private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{ {
bool known = _inboundPhysics.TryApplyPosition( bool known = _liveEntities!.TryApplyPosition(
update, update,
isLocalPlayer: update.Guid == _playerServerGuid, isLocalPlayer: update.Guid == _playerServerGuid,
forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null
@ -5674,6 +5803,7 @@ public sealed class GameWindow : IDisposable
entity.SetPosition(worldPos); entity.SetPosition(worldPos);
entity.ParentCellId = p.LandblockId; entity.ParentCellId = p.LandblockId;
entity.Rotation = rot; entity.Rotation = rot;
_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId);
// Commit B 2026-04-29 — keep the shadow registry in sync with // Commit B 2026-04-29 — keep the shadow registry in sync with
// server-authoritative position so the player's collision broadphase // server-authoritative position so the player's collision broadphase
@ -6402,7 +6532,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnTeleportStarted(uint sequence) private void OnTeleportStarted(uint sequence)
{ {
if (!_inboundPhysics.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence)) if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
return; return;
if (_playerController is not null) if (_playerController is not null)
@ -6417,19 +6547,15 @@ public sealed class GameWindow : IDisposable
/// <summary> /// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/> /// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// with the CAMERA's current world position as the anchor. For /// Known server GUIDs are translated to the canonical local entity ID so
/// scene-wide storm effects (lightning) the camera is the right /// logical teardown stops both Setup and network-triggered scripts through
/// reference frame since the flash is meant to be "around the /// one owner key. Their current entity position is the anchor. Unknown
/// player." For per-entity effects the runner's dedupe by /// owners retain the legacy camera anchor until Step 4's pending FIFO can
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays /// replay them after materialization.
/// working on different guids.
/// ///
/// <para> /// <para>
/// Improvements for follow-up: look up the guid's actual last- /// F754 remains a direct PhysicsScript DID. It is never resolved through a
/// known world position from <c>_worldState</c> so per-entity /// typed PhysicsScriptTable.
/// spell casts and emote gestures anchor correctly. For Phase 6
/// scope (lightning, which is Dereth-wide) the camera anchor is
/// sufficient.
/// </para> /// </para>
/// </summary> /// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message) 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); 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( private void UpdateSkyPes(
@ -8084,7 +8221,7 @@ public sealed class GameWindow : IDisposable
// landblocks are loaded into GpuWorldState before live-session // landblocks are loaded into GpuWorldState before live-session
// CreateObject events drain. The earlier order (live tick first, // CreateObject events drain. The earlier order (live tick first,
// streaming tick second) caused the initial CreateObject flood from // 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 // is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were
// silently dropped on the first frame and never rendered. // 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); uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF);
foreach (var entity in rescued) 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 // teleport's rescue/re-inject (GpuWorldState.DrainRescued) already
// placed at the destination center — back into the now-UNLOADED // placed at the destination center — back into the now-UNLOADED
// SOURCE landblock's pending bucket, where nothing recovers it // 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 // after teleporting out. The teleport machinery owns the avatar's
// landblock during transit; per-frame relocation resumes at // landblock during transit; per-frame relocation resumes at
// FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561 // FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561
// APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING -> // APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING ->
// DRAWSET ABSENT, never recovered. // DRAWSET ABSENT, never recovered.
if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace) 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 // 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. // exactly matching the old scan's miss case.
uint serverGuid = ae.Entity.ServerGuid; 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. // ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant // The server broadcasts UpdatePosition at ~5-10Hz for distant
// entities; without integration, remote chars jitter-hop between // entities; without integration, remote chars jitter-hop between
@ -12171,7 +12317,7 @@ public sealed class GameWindow : IDisposable
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|| _selection.SelectedObjectId is not uint selected || _selection.SelectedObjectId is not uint selected
|| !IsLiveHostileMonsterTarget(selected) || !IsLiveHostileMonsterTarget(selected)
|| !_entitiesByServerGuid.TryGetValue(selected, out var target)) || !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
return null; return null;
return target.Position return target.Position
@ -12221,7 +12367,7 @@ public sealed class GameWindow : IDisposable
mouseX: mouseX, mouseY: mouseY, mouseX: mouseX, mouseY: mouseY,
view: camera.View, projection: camera.Projection, view: camera.View, projection: camera.Projection,
viewport: viewport, viewport: viewport,
candidates: _entitiesByServerGuid.Values, candidates: _visibleEntitiesByServerGuid.Values,
skipServerGuid: includeSelf ? 0u : _playerServerGuid, skipServerGuid: includeSelf ? 0u : _playerServerGuid,
// Resolver: Setup's SelectionSphere is the ONLY input. If the // Resolver: Setup's SelectionSphere is the ONLY input. If the
// entity's Setup didn't bake a SelectionSphere, return null — // entity's Setup didn't bake a SelectionSphere, return null —
@ -12566,7 +12712,7 @@ public sealed class GameWindow : IDisposable
private bool IsCloseRangeTarget(uint targetGuid) private bool IsCloseRangeTarget(uint targetGuid)
{ {
if (_playerController is null) return false; if (_playerController is null) return false;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity)) if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return false; return false;
// Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic. // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
@ -12592,7 +12738,7 @@ public sealed class GameWindow : IDisposable
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void InstallSpeculativeTurnToTarget(uint targetGuid)
{ {
if (_playerController is not { } pc || pc.MoveTo is null) return; 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; return;
// Per-type use radius — same heuristic as the picker's // Per-type use radius — same heuristic as the picker's
@ -12683,7 +12829,7 @@ public sealed class GameWindow : IDisposable
uint? bestGuid = null; uint? bestGuid = null;
float bestDistanceSq = float.PositiveInfinity; float bestDistanceSq = float.PositiveInfinity;
foreach (var (guid, entity) in _entitiesByServerGuid) foreach (var (guid, entity) in _visibleEntitiesByServerGuid)
{ {
if (!IsLiveHostileMonsterTarget(guid)) if (!IsLiveHostileMonsterTarget(guid))
continue; continue;
@ -12723,10 +12869,10 @@ public sealed class GameWindow : IDisposable
{ {
if (guid == _playerServerGuid) if (guid == _playerServerGuid)
return false; return false;
if (!_entitiesByServerGuid.ContainsKey(guid)) if (!_visibleEntitiesByServerGuid.ContainsKey(guid))
return false; return false;
if (_entitiesByServerGuid.TryGetValue(guid, out var entity) if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)
&& _animatedEntities.TryGetValue(entity.Id, out var animated) && _animatedEntities.TryGetValue(entity.Id, out var animated)
&& animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead) && animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead)
return false; return false;
@ -12791,7 +12937,7 @@ public sealed class GameWindow : IDisposable
worldCenter = default; worldCenter = default;
worldRadius = 0f; 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 (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
if (spawn.SetupTableId is not uint setupId) return false; if (spawn.SetupTableId is not uint setupId) return false;
if (_dats is null) return false; if (_dats is null) return false;
@ -13829,7 +13975,14 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer?.Dispose(); _equippedChildRenderer?.Dispose();
_liveSessionController?.Dispose(); _liveSessionController?.Dispose();
_liveSession = null; _liveSession = null;
_inboundPhysics.Clear(); try
{
_liveEntities?.Clear();
}
finally
{
_entityScriptActivator?.ClearLegacyPendingOwners();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose(); _wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8 _envCellRenderer?.Dispose(); // Phase A8

View file

@ -27,20 +27,17 @@ public sealed record ScriptActivationInfo(
/// Stops the scripts and live emitters when the entity despawns. /// Stops the scripts and live emitters when the entity despawns.
/// ///
/// <para> /// <para>
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by /// Handles both server-spawned and dat-hydrated entities, always keyed by
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by /// canonical local <c>entity.Id</c>. The C.1.5a guard that early-returned for
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects /// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not /// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript. /// the network) also fire their DefaultScript.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the /// For live objects this is invoked by <c>LiveEntityRuntime</c> logical
/// adapter handles meshes + animation state, the activator handles scripts /// registration/teardown. <c>GpuWorldState</c> invokes it only for dat-static
/// + particles. Both are render-thread-only. The activator is invoked from /// landblock load, promotion, demotion, and unload paths.
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -55,6 +52,7 @@ public sealed class EntityScriptActivator
private readonly PhysicsScriptRunner _scriptRunner; private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink; private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly HashSet<uint> _legacyPendingOwners = new();
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the /// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their /// (scriptId, entityId) instance table and schedules hooks at their
@ -84,17 +82,14 @@ public sealed class EntityScriptActivator
/// <summary> /// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through /// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero, /// the script runner, keyed by canonical local <c>entity.Id</c>.
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
/// No-op if the entity has no DefaultScript (resolver returns null /// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script). /// or zero-script).
/// </summary> /// </summary>
public void OnCreate(WorldEntity entity) public void OnCreate(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; uint key = entity.Id;
if (key == 0) return; // malformed entity if (key == 0) return; // malformed entity
var info = _resolver(entity); var info = _resolver(entity);
@ -119,10 +114,8 @@ public sealed class EntityScriptActivator
/// <summary> /// <summary>
/// Stop every script instance the runner is tracking for this key, and /// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to it. Caller picks /// kill every live emitter the sink has attributed to the canonical
/// the key (the matching ServerGuid for live entities, or /// local entity id. Idempotent for unknown keys.
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
/// </summary> /// </summary>
public void OnRemove(uint key) public void OnRemove(uint key)
{ {
@ -130,4 +123,49 @@ public sealed class EntityScriptActivator
_scriptRunner.StopAllForEntity(key); _scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false); _particleSink.StopAllForEntity(key, fadeOut: false);
} }
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
{
if (serverGuid == 0 || serverGuid == canonicalLocalId)
return;
if (!_legacyPendingOwners.Remove(serverGuid))
return;
OnRemove(serverGuid);
}
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
public void ClearLegacyPendingOwners()
{
foreach (uint serverGuid in _legacyPendingOwners)
OnRemove(serverGuid);
_legacyPendingOwners.Clear();
}
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
} }

View file

@ -16,7 +16,8 @@ namespace AcDream.App.Streaming;
/// <para> /// <para>
/// Replaces the pre-streaming flat <c>_entities</c> list. This class is the /// Replaces the pre-streaming flat <c>_entities</c> list. This class is the
/// single point of truth for "what's in the world right now" and the only /// 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 <see cref="AcDream.App.World.LiveEntityRuntime"/>.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -25,13 +26,13 @@ namespace AcDream.App.Streaming;
/// X is loaded into <see cref="_loaded"/> (frequently true on the first /// X is loaded into <see cref="_loaded"/> (frequently true on the first
/// frame after login, where the entire post-login spawn flood drains /// frame after login, where the entire post-login spawn flood drains
/// before the streaming controller has finished loading the visible /// before the streaming controller has finished loading the visible
/// window). To survive this race, <see cref="AppendLiveEntity"/> stores /// window). To survive this race, <see cref="PlaceLiveEntityProjection"/> stores
/// orphaned spawns in a per-landblock pending bucket. When /// orphaned spawns in a per-landblock pending bucket. When
/// <see cref="AddLandblock"/> later loads the landblock, the matching /// <see cref="AddLandblock"/> later loads the landblock, the matching
/// pending entries are merged into the loaded record before the flat /// pending entries are merged into the loaded record before the flat
/// view rebuild. <see cref="RemoveLandblock"/> drops pending entries for /// view rebuild. <see cref="RemoveLandblock"/> retains non-persistent live
/// the same landblock — if the landblock just left the visible window, /// entries in that pending bucket so a later reload restores the same identity;
/// any spawns that came with it are no longer relevant. /// only dat-static entries are discarded with streaming residence.
/// </para> /// </para>
/// ///
/// <remarks> /// <remarks>
@ -41,7 +42,6 @@ namespace AcDream.App.Streaming;
public sealed class GpuWorldState public sealed class GpuWorldState
{ {
private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator; private readonly EntityScriptActivator? _entityScriptActivator;
/// <summary> /// <summary>
@ -58,12 +58,10 @@ public sealed class GpuWorldState
public GpuWorldState( public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null, LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null, System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null) EntityScriptActivator? entityScriptActivator = null)
{ {
_wbSpawnAdapter = wbSpawnAdapter; _wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded; _onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator; _entityScriptActivator = entityScriptActivator;
} }
@ -89,11 +87,15 @@ public sealed class GpuWorldState
// rebuilt on each add/remove. The renderer holds a reference to this // rebuilt on each add/remove. The renderer holds a reference to this
// list, so rebuilding it replaces the reference atomically. // list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>(); private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
private readonly HashSet<uint> _visibleLiveGuids = new();
public IReadOnlyList<WorldEntity> Entities => _flatEntities; public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys; public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
/// <summary> /// <summary>
/// Try to grab the loaded record for a landblock — useful for callers /// 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]); _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// Live entities (ServerGuid!=0) already had OnCreate fired at // LiveEntityRuntime owns activation for live objects. This static-only
// AppendLiveEntity; the filter avoids double-firing pending-bucket merges. // filter avoids replaying their defaults when a pending projection is
// drained into a newly loaded landblock.
if (_entityScriptActivator is not null) if (_entityScriptActivator is not null)
{ {
var loadedEntities = _loaded[landblock.LandblockId].Entities; 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 entity stays in the spawn landblock and gets frustum-culled when
/// the player walks away. /// the player walks away.
/// </summary> /// </summary>
public void RelocateEntity(WorldEntity entity, uint newCanonicalLb) public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
{ {
if (entity.ServerGuid == 0) return; if (entity.ServerGuid == 0) return;
@ -275,19 +278,19 @@ public sealed class GpuWorldState
// the player stranded, hidden, even after its landblock finished loading // the player stranded, hidden, even after its landblock finished loading
// (the AddLandblock pending-drain had already run empty before the churn // (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-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 // 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 // to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise. // await AddLandblock otherwise.
RemoveEntityFromAllBuckets(entity); RemoveEntityFromAllBuckets(entity);
AppendLiveEntity(canonical, entity); PlaceLiveEntityProjection(canonical, entity);
} }
/// <summary> /// <summary>
/// Remove <paramref name="entity"/> (by reference) from whichever /// Remove <paramref name="entity"/> (by reference) from whichever
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it /// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
/// currently occupies. At most one bucket holds a given entity, so this /// currently occupies. At most one bucket holds a given entity, so this
/// stops after the first hit. Called by <see cref="RelocateEntity"/> before /// stops after the first hit. Called by <see cref="RebucketLiveEntity"/> before
/// re-appending, so a stranded pending entity can be promoted. /// re-appending, so a stranded pending entity can be promoted.
/// </summary> /// </summary>
private void RemoveEntityFromAllBuckets(WorldEntity entity) private void RemoveEntityFromAllBuckets(WorldEntity entity)
@ -304,6 +307,7 @@ public sealed class GpuWorldState
if (j != i) newList.Add(entities[j]); if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock( _loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList); kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
RebuildFlatView();
return; return;
} }
} }
@ -318,28 +322,44 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId) public void RemoveLandblock(uint landblockId)
{ {
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_wbSpawnAdapter is not null) 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<WorldEntity>();
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 // Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for // 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. // 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) foreach (var entity in lb.Entities)
{ RetainOrRescue(entity, "loaded");
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}");
}
}
// C.1.5b: stop DefaultScript for each dat-hydrated entity in // C.1.5b: stop DefaultScript for each dat-hydrated entity in
// the landblock. Server-spawned entities are either being // the landblock. Server-spawned entities are either being
// rescued (script continues at the new LB) or were OnRemove'd // 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) if (_entityScriptActivator is not null)
{ {
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)
@ -352,7 +372,7 @@ public sealed class GpuWorldState
// #138 (secondary): rescue persistent entities sitting in the PENDING // #138 (secondary): rescue persistent entities sitting in the PENDING
// bucket too, not just the loaded list. The player is re-injected via // 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 // (GameWindow's DrainRescued loop); right after a teleport that
// landblock often hasn't streamed in yet, so the player lands in // landblock often hasn't streamed in yet, so the player lands in
// _pendingByLandblock. If that same landblock is then unloaded (a // _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 // "persistent ⇒ survives unload" contract and making the avatar
// vanish after a couple round-trips. Rescue them so DrainRescued // vanish after a couple round-trips. Rescue them so DrainRescued
// re-parks them at the next valid landblock. // 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) foreach (var entity in pendingForLb)
{ RetainOrRescue(entity, "pending");
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}");
}
}
} }
_pendingByLandblock.Remove(landblockId); _pendingByLandblock.Remove(canonical);
_aabbs.Remove(landblockId); if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
if (_loaded.Remove(landblockId)) if (_loaded.Remove(canonical))
RebuildFlatView(); RebuildFlatView();
} }
@ -384,7 +400,7 @@ public sealed class GpuWorldState
/// <summary> /// <summary>
/// Drain entities rescued from unloaded landblocks. The caller should /// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each via <see cref="AppendLiveEntity"/> with its current position. /// re-inject each through <c>LiveEntityRuntime.RebucketLiveEntity</c> with its current position.
/// </summary> /// </summary>
public List<WorldEntity> DrainRescued() public List<WorldEntity> DrainRescued()
{ {
@ -397,24 +413,16 @@ public sealed class GpuWorldState
/// <summary> /// <summary>
/// Remove every entity with the given <paramref name="serverGuid"/> from /// Remove every entity with the given <paramref name="serverGuid"/> from
/// all loaded landblocks AND all pending buckets, then rebuild the flat /// all loaded landblocks AND all pending buckets, then rebuild the flat
/// view. Used by the live <c>CreateObject</c> handler to de-duplicate /// view. Called by <c>LiveEntityRuntime</c> before rebucketing, temporary
/// when the server re-sends a spawn (visibility refresh, landblock /// world withdrawal, or logical teardown. It owns no renderer/script
/// crossing, etc.). Without this, multiple copies of the same NPC /// lifecycle and is safe for a still-live incarnation.
/// accumulate in the renderer, each with its own <c>PaletteOverride</c>
/// and <c>MeshRefs</c> — producing "NPC clothing flickers as I turn the
/// camera" because the depth test picks different duplicates frame-to-frame.
/// ///
/// Safe to call with a server guid that's not currently present — no-op. /// Safe to call with a server guid that's not currently present — no-op.
/// </summary> /// </summary>
public void RemoveEntityByServerGuid(uint serverGuid) public void RemoveLiveEntityProjection(uint serverGuid)
{ {
if (serverGuid == 0) return; 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; bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside. // Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
@ -435,8 +443,7 @@ public sealed class GpuWorldState
rebuiltLoaded = true; rebuiltLoaded = true;
} }
// Scrub pending buckets too — a duplicate CreateObject may arrive // Scrub pending buckets too so rebucketing cannot leave a second slot.
// while the landblock is still loading.
foreach (var kvp in _pendingByLandblock) foreach (var kvp in _pendingByLandblock)
kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid); kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
@ -444,9 +451,8 @@ public sealed class GpuWorldState
} }
/// <summary> /// <summary>
/// Append an entity to a specific landblock's slot. Used by the live /// Place an already-registered live entity in a landblock slot whose
/// CreateObject path where the server spawns entities at a server-side /// terrain may or may not be loaded yet.
/// position whose landblock may or may not be loaded yet.
/// ///
/// <para> /// <para>
/// The server's <c>landblockId</c> is in cell-resolved form /// The server's <c>landblockId</c> is in cell-resolved form
@ -468,14 +474,10 @@ public sealed class GpuWorldState
/// </list> /// </list>
/// </para> /// </para>
/// </summary> /// </summary>
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 // Spatial placement only. LiveEntityRuntime has already registered
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are // this incarnation's renderer and script resources exactly once.
// skipped by OnCreate — it returns null immediately for those.
_wbEntitySpawnAdapter?.OnCreate(entity);
_entityScriptActivator?.OnCreate(entity);
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
bool probePersistent = EntityVanishProbe.Enabled bool probePersistent = EntityVanishProbe.Enabled
&& entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid); && entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid);
@ -516,20 +518,16 @@ public sealed class GpuWorldState
/// Per Phase A.5 spec §4.4. /// Per Phase A.5 spec §4.4.
/// ///
/// <para> /// <para>
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike /// Only dat-static entity layers demote. Live server projections retain
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier /// their exact WorldEntity and bucket while terrain remains resident; no
/// only (procedural scenery, dat-static stabs/buildings) — they never /// logical or spatial lifetime callback is replayed.
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
/// The local player and other live server-spawned entities live in their
/// landblock via <c>RelocateEntity</c> per frame and are not affected
/// by Near→Far demotion of dat-static landblock layers.
/// </para> /// </para>
/// </summary> /// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId) 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 // 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. // cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return; 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( _loaded[canonical] = new LoadedLandblock(
lb.LandblockId, lb.LandblockId,
lb.Heightmap, lb.Heightmap,
System.Array.Empty<WorldEntity>()); retainedLive);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical); _pendingByLandblock.Remove(canonical);
}
RebuildFlatView(); RebuildFlatView();
} }
@ -578,11 +584,11 @@ public sealed class GpuWorldState
/// </summary> /// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities) public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> 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; uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) 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)) if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{ {
bucket = new List<WorldEntity>(); bucket = new List<WorldEntity>();
@ -614,6 +620,22 @@ public sealed class GpuWorldState
private void RebuildFlatView() private void RebuildFlatView()
{ {
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
var nowVisible = new HashSet<uint>(
_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(); if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
} }

View file

@ -1,86 +0,0 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// 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; <c>GameWindow</c> owns the replay half (it alone can rebuild a
/// mesh from a <c>CreateObject</c>).
///
/// <para>
/// <b>Why this exists (retail/ACE model).</b> A real AC client keeps its
/// <c>weenie_object_table</c> 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 <c>KnownObjects</c> set is never
/// cleared on a normal teleport — ACE relies on the client retaining the
/// table, cross-checked against <c>references/holtburger</c> +
/// <c>references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs</c>).
/// </para>
///
/// <para>
/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
/// RENDER entities for FPS but retains the accepted immutable spawns in
/// <c>InboundPhysicsStateController.Snapshots</c> (pruned by an explicit server
/// <c>DeleteObject</c> 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 <c>GameWindow</c> can replay them, making
/// re-delivery independent of the server.
/// </para>
/// </summary>
public static class LandblockEntityRehydrator
{
/// <summary>
/// A retained spawn reduced to just the fields the selection needs.
/// <paramref name="HasWorldMesh"/> 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 <c>OnLiveEntitySpawnedLocked</c>'s own guard).
/// </summary>
public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh);
/// <summary>
/// Canonical landblock id (low 16 bits forced to <c>0xFFFF</c>) — the same
/// keying <see cref="GpuWorldState.AppendLiveEntity"/> uses, so a
/// cell-resolved spawn id (<c>0xAAAA00CC</c>) and a streamed landblock id
/// (<c>0xAAAAFFFF</c>) compare equal when they name the same landblock.
/// </summary>
public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu;
/// <summary>
/// Select the guids whose retained spawn should be replayed for the
/// just-loaded <paramref name="loadedLandblockId"/>.
/// </summary>
/// <param name="loadedLandblockId">The landblock that just (re)loaded.</param>
/// <param name="retainedSpawns">Snapshot of the retained world-object spawns.</param>
/// <param name="presentServerGuids">
/// Server guids that already have a live render entity in
/// <see cref="GpuWorldState"/>. A guid here was either never dropped or was
/// already re-delivered by the server, so replaying it would be redundant.
/// </param>
/// <param name="playerServerGuid">
/// The local player's guid — never re-hydrated here; it is owned by the
/// persistent-entity rescue path (<see cref="GpuWorldState.MarkPersistent"/>
/// + <c>DrainRescued</c>), which preserves the player's live pose/position
/// rather than resetting it to the spawn snapshot.
/// </param>
public static List<uint> SelectGuidsToRehydrate(
uint loadedLandblockId,
IReadOnlyCollection<RetainedSpawn> retainedSpawns,
IReadOnlySet<uint> presentServerGuids,
uint playerServerGuid)
{
uint loadedCanonical = Canonicalize(loadedLandblockId);
var result = new List<uint>();
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;
}
}

View file

@ -454,7 +454,7 @@ public sealed class StreamingController
case LandblockStreamResult.Loaded loaded: case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Build, loaded.MeshData); _applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock); _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 // hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before. // re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public interface ILiveEntityAnimationRuntime
{
WorldEntity Entity { get; }
}
/// <summary>Remote motion state owned by a live object.</summary>
public interface ILiveEntityRemoteMotionRuntime
{
PhysicsBody Body { get; }
}
/// <summary>Marker for the projectile runtime added by the projectile phase.</summary>
public interface ILiveEntityProjectileRuntime { }
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
public interface ILiveEntityEffectProfile { }
public enum LiveEntityProjectionKind
{
World,
Attached,
}
/// <summary>
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
/// Spatial bucketing is deliberately absent: registering or removing meshes,
/// scripts, and effects is a logical-lifetime operation, not a landblock move.
/// </summary>
public interface ILiveEntityResourceLifecycle
{
void Register(WorldEntity entity);
void Unregister(WorldEntity entity);
}
/// <summary>Delegate adapter used by the App composition root.</summary>
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
{
private readonly Action<WorldEntity> _register;
private readonly Action<WorldEntity> _unregister;
public DelegateLiveEntityResourceLifecycle(
Action<WorldEntity> register,
Action<WorldEntity> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Update-thread owner of live server-object identity, accepted state, runtime
/// components, and logical teardown. <see cref="GpuWorldState"/> is a spatial
/// projection only; moving between loaded and pending buckets never replays a
/// create-time renderer, script, animation, or effect action.
///
/// <para>
/// 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 <c>2026-07-13-retail-projectile-vfx-pseudocode.md</c>,
/// <c>CPhysicsObj::set_description</c> (0x00514F40),
/// <c>CPhysicsObj::change_cell</c> (0x00513390), and
/// <c>CPhysicsObj::exit_world</c> (0x00514E60).
/// </para>
/// </summary>
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<LiveEntityRecord>? _tearDownRuntimeComponents;
private readonly InboundPhysicsStateController _inbound = new();
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
private readonly Dictionary<uint, WorldEntity> _materializedWorldEntitiesByGuid = new();
private readonly Dictionary<uint, WorldEntity> _visibleWorldEntitiesByGuid = new();
private readonly Dictionary<uint, uint> _guidByLocalId = new();
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
private uint _nextLocalEntityId;
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord>? 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<LiveEntityRecord> Records => _recordsByGuid.Values;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyDictionary<uint, WorldEntity> MaterializedWorldEntities =>
_materializedWorldEntitiesByGuid;
/// <summary>
/// Currently visible top-level world projections. Attached children and
/// pending projections are excluded from radar, picking, status, and target
/// candidate consumers.
/// </summary>
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
_visibleWorldEntitiesByGuid;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> AnimationRuntimes => _animationsByLocalId;
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> 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);
}
/// <summary>
/// Completes logical registration when a renderable projection can be
/// hydrated. The factory is called at most once for the incarnation.
/// </summary>
public WorldEntity? MaterializeLiveEntity(
uint serverGuid,
uint fullCellId,
Func<uint, WorldEntity> 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;
}
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
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;
}
/// <summary>
/// Removes only the render-bucket reference. The logical record and every
/// create-time resource remain alive for later projection/rebucketing.
/// </summary>
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<Exception>? failures = null;
try
{
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record))
{
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).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);
/// <summary>
/// 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.
/// </summary>
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.IsSpatiallyProjected
&& record.FullCellId != 0;
/// <summary>
/// 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.
/// </summary>
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<Exception>? failures = null;
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
{
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).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<Exception>? failures = null;
void RunCleanup(Action cleanup)
{
try
{
cleanup();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).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);
}
}

View file

@ -0,0 +1,97 @@
namespace AcDream.App.World;
/// <summary>
/// Strongly typed, storage-free view over animation components owned by a
/// <see cref="LiveEntityRuntime"/>. This keeps feature loops type-safe without
/// introducing a second component dictionary.
/// </summary>
public sealed class LiveEntityAnimationRuntimeView<TAnimation>
: IEnumerable<KeyValuePair<uint, TAnimation>>
where TAnimation : class, ILiveEntityAnimationRuntime
{
private readonly Func<LiveEntityRuntime?> _runtime;
public LiveEntityAnimationRuntimeView(Func<LiveEntityRuntime?> runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public int Count => _runtime()?.AnimationRuntimes.Count ?? 0;
public IEnumerable<uint> Keys =>
_runtime()?.AnimationRuntimes.Keys ?? Array.Empty<uint>();
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<KeyValuePair<uint, TAnimation>> GetEnumerator()
{
if (_runtime() is not { } runtime)
yield break;
foreach (var pair in runtime.AnimationRuntimes)
if (pair.Value is TAnimation typed)
yield return new KeyValuePair<uint, TAnimation>(pair.Key, typed);
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>Storage-free typed view over remote-motion components.</summary>
public sealed class LiveEntityRemoteMotionRuntimeView<TRemoteMotion>
where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime
{
private readonly Func<LiveEntityRuntime?> _runtime;
public LiveEntityRemoteMotionRuntimeView(Func<LiveEntityRuntime?> 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;
}

View file

@ -1,7 +1,7 @@
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
namespace AcDream.App.Rendering; namespace AcDream.App.World;
/// <summary> /// <summary>
/// Update-thread state machine for parent relations that may arrive before /// Update-thread state machine for parent relations that may arrive before

View file

@ -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<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
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<InvalidOperationException>(() => 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<AggregateException>(() => 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<ArgumentOutOfRangeException>(() => 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<System.Numerics.Matrix4x4>()));
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<AggregateException>(() => 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<WorldEntity>());
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<MeshRef>(),
};
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: positionSequence,
Physics: physics);
}
}

View file

@ -1,4 +1,3 @@
using AcDream.App.Rendering;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;

View file

@ -324,4 +324,42 @@ public sealed class EntityScriptActivatorTests
system.Tick(0.01f); system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount); 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);
}
} }

View file

@ -34,8 +34,8 @@ public sealed class PendingSpawnIntegrationTests
// the landblock streams in. ServerGuid != 0 makes this per-instance-tier. // the landblock streams in. ServerGuid != 0 makes this per-instance-tier.
var liveEntity = MakeServerSpawned( var liveEntity = MakeServerSpawned(
id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
// AppendLiveEntity takes the raw cell-form id; it canonicalises internally. // Projection placement takes the raw cell-form id; it canonicalises internally.
state.AppendLiveEntity(0x12340011u, liveEntity); state.PlaceLiveEntityProjection(0x12340011u, liveEntity);
Assert.Equal(1, state.PendingLiveEntityCount); Assert.Equal(1, state.PendingLiveEntityCount);
Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded 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. // Now a live entity arrives — landblock is already loaded.
var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); 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.Single(captured.IncrementCalls);
Assert.Equal(0, state.PendingLiveEntityCount); Assert.Equal(0, state.PendingLiveEntityCount);
} }

View file

@ -20,7 +20,7 @@ namespace AcDream.Core.Tests.Streaming;
/// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, /// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock,
/// RemoveLandblock, RemoveEntitiesFromLandblock), and that the /// RemoveLandblock, RemoveEntitiesFromLandblock), and that the
/// pending-bucket merge in AddLandblock does NOT double-fire for live /// pending-bucket merge in AddLandblock does NOT double-fire for live
/// entities that already had OnCreate at <see cref="GpuWorldState.AppendLiveEntity"/>. /// entities, whose logical activation belongs to LiveEntityRuntime.
/// </summary> /// </summary>
public sealed class GpuWorldStateActivatorTests public sealed class GpuWorldStateActivatorTests
{ {
@ -99,9 +99,9 @@ public sealed class GpuWorldStateActivatorTests
} }
[Fact] [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 // OnCreate fires once at that point. Then AddLandblock for the
// same canonical id pulls the pending entity into the loaded list. // same canonical id pulls the pending entity into the loaded list.
// The new fire-site MUST NOT call OnCreate again (the live entity // 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 p = BuildPipeline(scriptId: 0xAAu);
var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero);
p.State.AppendLiveEntity(0xA9B4FFFFu, live); p.State.PlaceLiveEntityProjection(0xA9B4FFFFu, live);
var emptyLb = MakeStubLandblock(0xA9B4FFFFu); var emptyLb = MakeStubLandblock(0xA9B4FFFFu);
p.State.AddLandblock(emptyLb); p.State.AddLandblock(emptyLb);
p.Runner.Tick(0.001f); p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls); // exactly one — no double-fire Assert.Empty(p.Recording.Calls);
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
} }
[Fact] [Fact]

View file

@ -29,7 +29,7 @@ public class GpuWorldStateTests
}; };
[Fact] [Fact]
public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately() public void PlaceLiveEntityProjection_LandblockAlreadyLoaded_AppendsImmediately()
{ {
var state = new GpuWorldState(); var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -37,20 +37,20 @@ public class GpuWorldStateTests
// Server sends a spawn at landblock 0xA9B40011 — same landblock, // Server sends a spawn at landblock 0xA9B40011 — same landblock,
// cell index 0x0011. Canonicalize drops the cell index, lookup // cell index 0x0011. Canonicalize drops the cell index, lookup
// succeeds, entity lands in the loaded landblock immediately. // succeeds, entity lands in the loaded landblock immediately.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42)); state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
Assert.Single(state.Entities); Assert.Single(state.Entities);
Assert.Equal(0u, (uint)state.PendingLiveEntityCount); Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
} }
[Fact] [Fact]
public void AppendLiveEntity_LandblockNotLoaded_ParksInPending() public void PlaceLiveEntityProjection_LandblockNotLoaded_ParksInPending()
{ {
var state = new GpuWorldState(); var state = new GpuWorldState();
// No landblock loaded — the spawn must survive instead of being // No landblock loaded — the spawn must survive instead of being
// silently dropped (the bug from Phase A.1's first live run). // 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.Empty(state.Entities); // not visible yet
Assert.Equal(1, state.PendingLiveEntityCount); Assert.Equal(1, state.PendingLiveEntityCount);
@ -62,9 +62,9 @@ public class GpuWorldStateTests
var state = new GpuWorldState(); var state = new GpuWorldState();
// Three spawns arrive before the landblock loads. // Three spawns arrive before the landblock loads.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3)); state.PlaceLiveEntityProjection(0xA9B40033u, MakeStubEntity(3));
Assert.Equal(3, state.PendingLiveEntityCount); Assert.Equal(3, state.PendingLiveEntityCount);
Assert.Empty(state.Entities); Assert.Empty(state.Entities);
@ -82,8 +82,8 @@ public class GpuWorldStateTests
{ {
var state = new GpuWorldState(); var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF state.PlaceLiveEntityProjection(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
// Loading 0xA9B4FFFF only drains its own bucket. // Loading 0xA9B4FFFF only drains its own bucket.
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -93,12 +93,12 @@ public class GpuWorldStateTests
} }
[Fact] [Fact]
public void RelocateEntity_StrandedInPending_MovesToLoadedTarget() public void RebucketLiveEntity_StrandedInPending_MovesToLoadedTarget()
{ {
// Regression: the cold-spawn / run-out "invisible player" bug // Regression: the cold-spawn / run-out "invisible player" bug
// (2026-07-03). A persistent (server-spawned) entity that spawned into a // (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 // 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 // it draws — but the old implementation scanned ONLY _loaded, so it
// silently no-op'd on a pending entity and the player stayed hidden // silently no-op'd on a pending entity and the player stayed hidden
// forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)" // forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)"
@ -118,16 +118,16 @@ public class GpuWorldStateTests
state.MarkPersistent(0x5000000Au); state.MarkPersistent(0x5000000Au);
// Spawned before its landblock streamed in → parked in pending, hidden. // Spawned before its landblock streamed in → parked in pending, hidden.
state.AppendLiveEntity(0xADAF0011u, player); state.PlaceLiveEntityProjection(0xADAF0011u, player);
Assert.Empty(state.Entities); Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount); Assert.Equal(1, state.PendingLiveEntityCount);
// The player's current landblock IS loaded now (the live log shows the // 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 // to keep the player homed to its current landblock — must promote the
// stranded pending entity into the loaded bucket so it draws. // stranded pending entity into the loaded bucket so it draws.
state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu)); state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu));
state.RelocateEntity(player, 0xBBBB0011u); state.RebucketLiveEntity(player, 0xBBBB0011u);
Assert.Single(state.Entities); // now drawn Assert.Single(state.Entities); // now drawn
Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded
@ -139,8 +139,8 @@ public class GpuWorldStateTests
var state = new GpuWorldState(); var state = new GpuWorldState();
// Spawns for landblock 0xA9B4FFFF arrive while it's pending. // Spawns for landblock 0xA9B4FFFF arrive while it's pending.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2));
Assert.Equal(2, state.PendingLiveEntityCount); Assert.Equal(2, state.PendingLiveEntityCount);
// Player moves away — the streamer says "this landblock is no // Player moves away — the streamer says "this landblock is no
@ -159,7 +159,7 @@ public class GpuWorldStateTests
var state = new GpuWorldState(); var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.Single(state.Entities); Assert.Single(state.Entities);
state.RemoveLandblock(0xA9B4FFFFu); state.RemoveLandblock(0xA9B4FFFFu);
@ -172,7 +172,7 @@ public class GpuWorldStateTests
{ {
var state = new GpuWorldState(); var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -193,7 +193,7 @@ public class GpuWorldStateTests
[Fact] [Fact]
public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket() 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 // 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 // lands in the pending bucket. If that landblock is then unloaded before
// it finishes loading, the persistent entity must still be rescued — // it finishes loading, the persistent entity must still be rescued —
@ -203,7 +203,7 @@ public class GpuWorldStateTests
state.MarkPersistent(playerGuid); state.MarkPersistent(playerGuid);
var player = MakeServerEntity(id: 1, serverGuid: 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); Assert.Equal(1, state.PendingLiveEntityCount);
state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending
@ -212,17 +212,36 @@ public class GpuWorldStateTests
} }
[Fact] [Fact]
public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity() public void RemoveLandblock_RetainsNonPersistentLiveProjectionForSameIdentityReload()
{ {
// A normal server object (door) in the pending bucket is NOT persistent; // A normal server object (door) is not rescued with the moving player,
// it must still be dropped (and is recoverable via #138 re-hydrate from // but its exact projection remains pending for this landblock. Reload
// the retained inbound spawn snapshot, not via the rescue path). // must merge that identity instead of reconstructing from CreateObject.
var state = new GpuWorldState(); var state = new GpuWorldState();
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
state.AppendLiveEntity(0xA9B40011u, door); state.PlaceLiveEntityProjection(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu); state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.DrainRescued()); 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));
} }
} }

View file

@ -37,6 +37,32 @@ public class GpuWorldStateTwoTierTests
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident 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<MeshRef>(),
};
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] [Fact]
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord() public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
{ {
@ -118,7 +144,6 @@ public class GpuWorldStateTwoTierTests
int callCount = 0; int callCount = 0;
var state = new GpuWorldState( var state = new GpuWorldState(
wbSpawnAdapter: null, wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: id => { observed = id; callCount++; }); onLandblockUnloaded: id => { observed = id; callCount++; });
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1))); state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1)));
@ -144,7 +169,6 @@ public class GpuWorldStateTwoTierTests
int callCount = 0; int callCount = 0;
var state = new GpuWorldState( var state = new GpuWorldState(
wbSpawnAdapter: null, wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: _ => callCount++); onLandblockUnloaded: _ => callCount++);
// Landblock never loaded. // Landblock never loaded.

View file

@ -1,128 +0,0 @@
using System.Collections.Generic;
using AcDream.App.Streaming;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// #138 — selection logic for re-hydrating server objects when a landblock
/// reloads after a dungeon collapse (or a Near→Far demote). See
/// <see cref="LandblockEntityRehydrator"/> for the retail/ACE rationale.
/// </summary>
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<uint>(),
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<uint>(),
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<uint>(),
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<uint> { 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<uint>(),
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<uint>(),
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<uint> { chestGuid }, PlayerGuid);
Assert.Equal(new HashSet<uint> { DoorGuid, npcGuid }, new HashSet<uint>(result));
}
}

View file

@ -78,19 +78,20 @@ public class StreamingControllerTests
// Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg // Entities (positional record). Adjust if the first positional arg
// name differs. // name differs.
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>()); const uint landblockId = 0x3232FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
// A.5 T10-T12 follow-up: use a real empty mesh instance instead of // 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 // default! so any future test that flows MeshData through the apply
// callback gets a non-null reference to inspect rather than an NRE. // callback gets a non-null reference to inspect rather than an NRE.
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(), System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>()); System.Array.Empty<uint>());
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); controller.Tick(50, 50);
Assert.Single(applied); Assert.Single(applied);
Assert.True(state.IsLoaded(0x32320FFEu)); Assert.True(state.IsLoaded(landblockId));
} }
[Fact] [Fact]
@ -102,12 +103,13 @@ public class StreamingControllerTests
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
(_, _) => { }, state, nearRadius: 2, farRadius: 2); (_, _) => { }, state, nearRadius: 2, farRadius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>()); const uint landblockId = 0x3232FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb); state.AddLandblock(lb);
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(0x32320FFEu)); fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));
controller.Tick(50, 50); controller.Tick(50, 50);
Assert.False(state.IsLoaded(0x32320FFEu)); Assert.False(state.IsLoaded(landblockId));
} }
} }