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:
parent
8a5d77f7f4
commit
8dd996053d
24 changed files with 2449 additions and 631 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:309573–309597 |
|
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
||||||
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
| AP-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 |
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`.
|
- **Wave 4 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 0–2 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects; AP-69 is narrowed to the missing retail liveness cull and AD-32 to the remaining mixed pre-create queue.
|
||||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
- **M2 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`.
|
||||||
|
|
|
||||||
|
|
@ -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 0–2
|
||||||
|
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
|
||||||
|
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
|
||||||
|
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
|
||||||
|
`LiveEntityRuntime` now separates logical lifetime from spatial rebucketing.
|
||||||
|
Landblock churn and equipment attachment preserve one stable local entity and
|
||||||
|
cannot replay create-time scripts. The remaining steps port DAT hook decoding,
|
||||||
|
per-owner effect scheduling, live animated attachment poses, projectile motion
|
||||||
|
and sweeps, and Hidden/UnHide portal presentation.
|
||||||
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
732
src/AcDream.App/World/LiveEntityRuntime.cs
Normal file
732
src/AcDream.App/World/LiveEntityRuntime.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/AcDream.App/World/LiveEntityRuntimeViews.cs
Normal file
97
src/AcDream.App/World/LiveEntityRuntimeViews.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
876
tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
Normal file
876
tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue