From 8d63e5c28a335d9815bafa65183d86f32377883b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Jul 2026 15:47:00 +0200 Subject: [PATCH] test(vfx): harden live missile and effect lifetimes Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset. Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures. Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green. Co-Authored-By: Codex --- docs/architecture/acdream-architecture.md | 41 +- .../retail-divergence-register.md | 8 +- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 34 +- ...-07-13-retail-projectile-vfx-pseudocode.md | 93 ++ .../Physics/RemoteTeleportController.cs | 13 + src/AcDream.App/Rendering/GameWindow.cs | 2 +- .../Rendering/Vfx/EntityEffectController.cs | 28 +- .../Vfx/LiveEntityLightController.cs | 3 + src/AcDream.App/Streaming/GpuWorldState.cs | 107 +- .../World/LiveEntityPresentationController.cs | 4 + src/AcDream.App/World/LiveEntityRuntime.cs | 415 ++++++- src/AcDream.Core/Lighting/LightingHookSink.cs | 9 + .../Physics/ShadowObjectRegistry.cs | 12 + src/AcDream.Core/Vfx/ParticleHookSink.cs | 12 + src/AcDream.Core/Vfx/PhysicsScriptRunner.cs | 1 + .../Vfx/EntityEffectControllerTests.cs | 17 + .../Wb/EntitySpawnAdapterLifetimeTests.cs | 116 ++ .../Streaming/GpuWorldStateVisibilityTests.cs | 139 +++ .../World/LiveEntityLifecycleStressTests.cs | 1049 +++++++++++++++++ .../World/LiveEntityRuntimeTests.cs | 700 +++++++++++ 21 files changed, 2704 insertions(+), 100 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 6bf1b2d6..3f845c19 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -60,7 +60,7 @@ well-defined interfaces that the retail client never had. ├──────────────────────────────────────────────────────────────┤ │ LAYER 1: Renderer │ │ Silk.NET OpenGL 4.3 core profile │ -│ TerrainRenderer, StaticMeshRenderer, TextureCache │ +│ TerrainModernRenderer, WbDrawDispatcher, EnvCellRenderer │ │ Shaders (terrain blending, mesh lighting, translucency) │ │ ► completely different from retail (D3D7), same visual │ │ output │ @@ -186,13 +186,14 @@ src/ RemoteTeleportHook.cs -> ordered retail teleport teardown seam RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit World/ - LiveEntityRuntime.cs -> canonical logical identity/state/spatial ownership + LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain Rendering/ GameWindow.cs -> still owns too much runtime wiring - TerrainRenderer.cs -> done - StaticMeshRenderer.cs -> done + TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path + Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch + Wb/EnvCellRenderer.cs -> indoor cell-shell draw path TextureCache.cs -> done ChaseCamera.cs -> done FlyCamera.cs -> done @@ -285,7 +286,12 @@ What exists and is active: transition FIFO; `LiveEntityRuntime` rejects delayed duplicate edges. A rollback/rebucket inside a visibility observer therefore cannot expose a remove/add pulse, leave stale final visibility, or reorder the final - presentation edge. + presentation edge. Observer failures are reported only after both spatial and + canonical runtime cell/projection state have committed. + Every projection operation also carries a per-record mutation token; if a + synchronous observer replaces the GUID or reprojects that same record, the + displaced outer operation cannot overwrite the newer cell or presentation + indices. - `BSPQuery` contains the partial retail-style BSP collision dispatcher used by the transition path. - `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, @@ -326,12 +332,20 @@ Ownership by phase: `LiveEntityRuntime` is now the shipped bridge to this target. It owns one `LiveEntityRecord` per accepted server-object incarnation, ServerGuid-to-local-id -translation, accepted snapshots/timestamp gates, animation and remote-motion -components, parent-event state, effect-profile defaults, and exact logical +translation, accepted snapshots/timestamp gates, the canonical `PhysicsBody`, +animation/remote-motion/projectile components, parent-event state, +effect-profile defaults, and exact logical teardown. Live `PhysicsDesc` effect fields replace Setup defaults on that same record; rebucketing never recreates them. `GpuWorldState` owns spatial buckets only: register/rebucket/unregister are separate operations, -and landblock reloads reuse the same `WorldEntity` without replaying renderer or +logical teardown removes the exact `WorldEntity` incarnation rather than every +projection sharing its server GUID, and per-GUID/session mutation epochs prevent +a callback from resurrecting an outer CreateObject after delete/reset. Per-GUID +epoch tombstones remain until session clear, so delete-then-create cannot repeat +an outer operation's epoch (the ABA problem). Resource registration is an atomic +boundary, superseded cleanup failures surface at the runtime boundary, and +visibility observers are failure-isolated without interrupting canonical commits. +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. Raw server PhysicsState and the final state produced by retail's @@ -416,7 +430,7 @@ public sealed class GameEntity // Motion (ported from CMotionInterp) public MotionInterpreter Motion { get; } // walk/run/turn state - // Render output (consumed by StaticMeshRenderer) + // Render output (consumed by WbDrawDispatcher) public IReadOnlyList MeshRefs { get; } // Per-frame update (matches retail update_object) @@ -467,8 +481,10 @@ matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior. 5. Render tick └── Read current entity mesh refs, draw - TerrainRenderer.Draw, StaticMeshRenderer.Draw - (frustum cull, translucency pass, etc.) + TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer + (frustum cull, translucency pass, portal visibility, etc.) + Projectiles remain ordinary live-entity draws; there is no global or + projectile-specific render pass. 6. Plugin tick └── Fire IEvents, drain IActions queue @@ -484,8 +500,7 @@ matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior. ## Render Pipeline (SSOT — current state + unified-PView target) -> **The per-frame render step above is STALE** (it names deleted classes -> `TerrainRenderer` / `StaticMeshRenderer`). The modern path (Phase N.5, mandatory) is +> The modern path (Phase N.5, mandatory) is > `WbDrawDispatcher` (entities) + `EnvCellRenderer` (indoor cell shells) + > `TerrainModernRenderer` (terrain), fed by the portal-visibility stack. This section is > the authoritative description of how indoor/outdoor rendering is *supposed* to work and diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9de7f4db..82c06c8a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -106,7 +106,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 88 active rows +## 3. Documented approximation (AP) — 87 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -188,8 +188,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | -| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | -| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch is dormant until missiles | When missiles arm PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | +| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | +| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | | AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) | | AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) | @@ -215,7 +215,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | -## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) +## 4. Temporary stopgap (TS) — 34 active rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 28cd1021..cc13f126 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -518,6 +518,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant. - **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion. - **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains because the separate 25-second/384-metre liveness cull is still unported. +- **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index f80198f4..cfb67b8c 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -471,8 +471,8 @@ include dungeons. `PlayerDescription.Options1` preserves retail's player secure-trade preference. See `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. -- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–8 - independently reviewed 2026-07-14)** — named-retail projectile and +- **M2/M3 missile, effect, and portal presentation campaign (automated Steps 0–9 + complete 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. @@ -533,8 +533,34 @@ include dungeons. `teleport_hook` teardown followed by a placement snap before the airborne gate. Ordinary visible CreateObject does not fabricate UnHide; pending F754/F755 packets replay only after construction-state effects. TS-43 is - retired. The remaining Step 9 is final hardening plus the two-client visual - gate. + retired. Step 9 adds a deterministic 96-owner whole-lifecycle gate spanning + projectile ticks, repeated DAT-effect plays, loaded↔pending landblock churn, + dynamic lights, particle bindings, collision shadows, rapid deletion, + same-GUID generation reuse, unknown pre-create effect packets, and session + reset. A separate 96-owner renderer-seam gate balances every + `EntitySpawnAdapter` mesh reference. Teardown now proves zero retained live + records/bodies/projectiles, render owners, spatial buckets/rescues, + PhysicsScript queues/anchors, effect profiles/packets, emitters/logical IDs, + poses, lights, and active/suspended shadow registrations. The hardening pass + also fixed undrained persistent-rescue retention and rejects queued spatial + visibility edges that no longer match current incarnation truth. A companion + twelve-cycle gate drives exact recall motion `0x10000153` through CallPES, + Hidden, unloaded-destination placement, hydration, and UnHide. Logical + teardown is incarnation-specific across callback-driven GUID reuse; per-GUID + epochs reject create resurrection after a nested delete/reset, resource + registration is atomic, terminal session clear rejects registrations outside + its teardown snapshot, and visibility fan-out drains all owners before + aggregating observer failures. Canonical cell/projection state finishes + committing before those aggregates rethrow; per-GUID epoch tombstones survive + accepted delete until session clear, and superseded creates cannot discard a + prior-incarnation cleanup failure. Per-record projection tokens also prevent + an outer rebucket/withdrawal from overwriting a replacement incarnation or a + newer same-record projection created synchronously by an observer. + TS-26's + `UpdatePosition` freshness gap is retired by the nine-channel timestamp + gates; TS-49 remains the explicit + DetectionManager/`LeftDetection` gap. The final two-client visual gate is + still 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 funnel and action-stamp gate, so ACE's server-selected melee/missile action diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index 26a657d6..96541259 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -1076,3 +1076,96 @@ publishes final part poses later in the same frame. PhysicsScript/particle/light ticks therefore observe the projectile's current predicted root. The controller never creates impact effects, collision messages, damage, or deletion; ACE's later packets remain authoritative for every gameplay outcome. + +## Step 9 — deterministic lifecycle-hardening map (2026-07-14) + +Step 9 adds no new AC algorithm and therefore no replacement retail formula. +It verifies that the mechanisms above compose without retaining a displaced +object incarnation. The acceptance oracle is ownership symmetry: + +```text +create 96 live missile/effect owners at a fixed clock and RNG seed + queue F754 before CreateObject + register one canonical live record/local ID + materialize through the ordinary live-entity resource seam + bind the projectile to that record's canonical PhysicsBody + register its collision shadow, effect profile, pose, emitter, and light + +advance all owners together + execute the initial PES hook and retain a future hook in each serial FIFO + tick the ordinary ProjectileController path + never create a projectile-specific renderer registration or draw pass + +churn spatial residence + rebucket half loaded -> pending -> loaded + unload/reload that destination landblock + retain the exact local ID, body, renderer owner, effect owner, and FIFO + pause/resume particle presentation and dynamic lights at projection edges + +churn logical lifetime + rapidly delete 32 owners + recreate the same server GUIDs with a newer INSTANCE_TS + prove the replacement owns new local IDs and no prior-generation resource + mix individual deletes with session reset and never-created pending F754 + +terminal assertions + zero live/materialized records and null body/projectile/effect/world refs + zero loaded/pending/rescued live projection references + zero render owners and balanced EntitySpawnAdapter mesh refcounts + zero active, retained, and suspended collision-shadow registrations + zero ready effect profiles and pending F754/F755 packets + zero PhysicsScript owner queues, anchors, and delayed CallPES hooks + zero particle emitters/particles/bindings/logical IDs/owner/render-pass maps + zero effect poses and light controller/sink/manager owner maps + +repeat the recall/portal lifecycle + resolve exact action motion 0x10000153 through an in-memory MotionTable + drain its animation CallPES through the shared router and serial scheduler + apply Hidden, park authoritative placement in an unloaded destination + hydrate and collision-seat that same body, then apply UnHide + repeat twelve times while retaining one local ID and one logical owner + finish with zero placement, presentation, script, particle, pose, and shadow owners +``` + +The stress design exposed five ownership defects rather than masking them: + +1. `GpuWorldState::RemoveLandblock` can rescue a persistent player projection + before the next frame drains it. Rebucket/delete now scrubs an undrained + rescue reference, while the session-scoped persistent GUID classification + deliberately survives same-GUID generation replacement and clears only at + accepted delete/session teardown. +2. Spatial visibility callbacks are serialized and can be re-entrant. An old + queued GUID edge is now ignored unless it still equals current spatial truth, + so deleting/recreating a GUID inside an observer cannot hide or re-register + the replacement incarnation. Each rebucket/withdrawal also captures the + record reference plus a projection mutation token; an observer that replaces + the GUID or reprojects the same record supersedes the outer transaction. +3. Logical delete and generation replacement previously tore down spatial and + runtime indices by GUID after arbitrary callbacks. Teardown now captures and + removes the accepted record before callbacks, removes its exact WorldEntity + reference, and clears generation/local-ID owners only when they still belong + to that record. A per-GUID mutation epoch prevents an outer CreateObject from + resurrecting after a nested delete/reset, while unrelated GUID activity does + not cancel it. Its tombstone remains until session clear so nested + delete-then-create cannot repeat the outer operation's epoch. A teardown + failure is thrown at the runtime boundary even when a nested newer generation + supersedes the outer create. Materialization is an atomic boundary: logical mutation from a + resource-registration callback is rejected and rolled back, and a replacement + registered during old teardown cannot materialize until that teardown ends. +4. Session Clear previously snapshotted records while allowing callbacks to + register owners outside the snapshot. Registration is now rejected during + terminal clear, preventing untracked renderer/script resources; snapshot + entries already deleted by an earlier callback are skipped rather than torn + down twice. +5. Visibility dispatch previously stopped at the first throwing observer. Both + spatial and live-presentation fan-outs now drain every subscriber and every + queued owner edge before aggregating failures, so one faulty sink cannot + strand lights/particles or leak a stale edge into GUID reuse. Rebucket and + withdrawal finish their canonical cell, projection, and presentation-index + commits before rethrowing those observer aggregates. + +The deterministic gate now validates the exact recall action ID and its +animation-hook/PES route plus Hidden/remote-placement/UnHide ownership. Installed +DAT colors, trail/cloud appearance, remote portal observation, and impact-origin +presentation remain visual acceptance items; an in-memory conformance fixture +cannot claim pixel appearance. diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs index d1a9a010..4fada684 100644 --- a/src/AcDream.App/Physics/RemoteTeleportController.cs +++ b/src/AcDream.App/Physics/RemoteTeleportController.cs @@ -176,12 +176,25 @@ internal sealed class RemoteTeleportController : IDisposable _pending.Remove(serverGuid); } + internal void Forget(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending) + && pending.Generation == record.Generation + && (record.WorldEntity is null + || ReferenceEquals(pending.Entity, record.WorldEntity))) + { + _pending.Remove(record.ServerGuid); + } + } + internal void Clear() { _pending.Clear(); } internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid); + internal int PendingPlacementCount => _pending.Count; /// /// Transfers any older deferred shadow restoration before the accepted diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e31e9323..a132f01f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5044,7 +5044,7 @@ public sealed class GameWindow : IDisposable { () => _liveEntityPresentation?.Forget(record), () => _entityEffects?.OnLiveEntityUnregistered(record), - () => _remoteTeleportController?.Forget(serverGuid), + () => _remoteTeleportController?.Forget(record), }; if (_pendingPostArrivalAction is { Guid: var pendingGuid } && pendingGuid == serverGuid) diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index e4ca0e17..792cc8d6 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -33,7 +33,7 @@ public sealed class EntityEffectController : IAnimationHookSink private readonly Action _ownerUnregistered; private readonly Action _ownerSoundTableChanged; private readonly Dictionary _profilesByLocalId = new(); - private readonly HashSet _readyServerGuids = new(); + private readonly Dictionary _readyGenerationByServerGuid = new(); private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); private readonly HashSet _syntheticOwners = new(); @@ -125,7 +125,7 @@ public sealed class EntityEffectController : IAnimationHookSink return false; } - _readyServerGuids.Add(serverGuid); + _readyGenerationByServerGuid[serverGuid] = record.Generation; _profilesByLocalId[entity.Id] = profile; _runner.SetOwnerAnchor(entity.Id, entity.Position); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); @@ -197,8 +197,18 @@ public sealed class EntityEffectController : IAnimationHookSink public void OnLiveEntityUnregistered(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); - _pendingByServerGuid.Remove(record.ServerGuid); - _readyServerGuids.Remove(record.ServerGuid); + if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || ReferenceEquals(current, record)) + { + _pendingByServerGuid.Remove(record.ServerGuid); + } + if (_readyGenerationByServerGuid.TryGetValue( + record.ServerGuid, + out ushort readyGeneration) + && readyGeneration == record.Generation) + { + _readyGenerationByServerGuid.Remove(record.ServerGuid); + } if (record.LocalEntityId is not { } localId) return; _profilesByLocalId.Remove(localId); @@ -212,7 +222,7 @@ public sealed class EntityEffectController : IAnimationHookSink public void ClearNetworkState() { - foreach (uint serverGuid in _readyServerGuids.ToArray()) + foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) { if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId)) { @@ -221,14 +231,14 @@ public sealed class EntityEffectController : IAnimationHookSink _ownerUnregistered(localId); } } - _readyServerGuids.Clear(); + _readyGenerationByServerGuid.Clear(); _pendingByServerGuid.Clear(); } /// Refreshes every live root after animation/movement. public void RefreshLiveOwnerPoses() { - foreach (uint serverGuid in _readyServerGuids.ToArray()) + foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) { if (!TryGetReadyLocalId(serverGuid, out uint localId)) continue; @@ -360,7 +370,9 @@ public sealed class EntityEffectController : IAnimationHookSink private bool TryGetReadyLocalId(uint serverGuid, out uint localId) { - if (_readyServerGuids.Contains(serverGuid) + if (_readyGenerationByServerGuid.TryGetValue(serverGuid, out ushort readyGeneration) + && _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) + && record.Generation == readyGeneration && _liveEntities.TryGetLocalEntityId(serverGuid, out localId) && _profilesByLocalId.ContainsKey(localId)) { diff --git a/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs index 9c0c4a76..0ec9e730 100644 --- a/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs +++ b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs @@ -114,6 +114,9 @@ public sealed class LiveEntityLightController : IDisposable public void Refresh() => _lighting.RefreshAttachedLights(); + internal int TrackedOwnerCount => _serverGuidByOwner.Count; + internal int PresentedOwnerCount => _presentOwners.Count; + /// /// Attached projection visibility can become true before its holding-part /// composition is published. The attachment owner calls this barrier only diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b0601002..beb443a2 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -199,6 +199,10 @@ public sealed class GpuWorldState /// pending path is doing its job. /// public int PendingLiveEntityCount => _pendingByLandblock.Values.Sum(list => list.Count); + public int PendingBucketCount => _pendingByLandblock.Count; + public int PendingRescueCount => _persistentRescued.Count; + public int PersistentGuidCount => _persistentGuids.Count; + public int PendingVisibilityTransitionCount => _visibilityTransitions.Count; public void AddLandblock(LoadedLandblock landblock) { @@ -449,12 +453,84 @@ public sealed class GpuWorldState } // Scrub pending buckets too so rebucketing cannot leave a second slot. - foreach (var kvp in _pendingByLandblock) - kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid); + foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) + { + List bucket = _pendingByLandblock[landblockId]; + bucket.RemoveAll(e => e.ServerGuid == serverGuid); + if (bucket.Count == 0) + _pendingByLandblock.Remove(landblockId); + } + + // A persistent projection may have been rescued by RemoveLandblock + // and not yet drained by the next GameWindow frame. Rebucketing or + // logical teardown before that drain must remove the stale rescue + // reference or it can later re-inject a deleted/duplicated object. + _persistentRescued.RemoveAll(e => e.ServerGuid == serverGuid); if (rebuiltLoaded) RebuildFlatView(); } + /// + /// Removes one exact live projection incarnation. Unlike the GUID overload, + /// this cannot detach a replacement that reused the same server GUID from a + /// re-entrant logical teardown callback. + /// + public void RemoveLiveEntityProjection(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) return; + + bool rebuiltLoaded = false; + foreach (var kvp in _loaded.ToArray()) + { + IReadOnlyList entities = kvp.Value.Entities; + if (!entities.Any(candidate => ReferenceEquals(candidate, entity))) + continue; + + _loaded[kvp.Key] = new LoadedLandblock( + kvp.Value.LandblockId, + kvp.Value.Heightmap, + entities.Where(candidate => !ReferenceEquals(candidate, entity)).ToArray()); + rebuiltLoaded = true; + } + + foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) + { + List bucket = _pendingByLandblock[landblockId]; + bucket.RemoveAll(candidate => ReferenceEquals(candidate, entity)); + if (bucket.Count == 0) + _pendingByLandblock.Remove(landblockId); + } + + _persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity)); + + if (rebuiltLoaded) + RebuildFlatView(); + } + + /// + /// Ends all spatial lifetime retained for one server object. Temporary + /// rebucketing uses and keeps the + /// persistence classification; logical delete also forgets that class so + /// a later session/GUID reuse cannot be rescued as the old player. + /// + public void ForgetLiveEntity(uint serverGuid) + { + RemoveLiveEntityProjection(serverGuid); + _persistentGuids.Remove(serverGuid); + _persistentInFlatProbe.Remove(serverGuid); + } + + /// Clears session-scoped persistence and undrained rescues. + public void ClearLiveEntityLifetimeState() + { + _persistentGuids.Clear(); + _persistentRescued.Clear(); + _persistentInFlatProbe.Clear(); + _visibilityTransitions.Clear(); + _visibleLiveGuids.Clear(); + } + /// /// Place an already-registered live entity in a landblock slot whose /// terrain may or may not be loaded yet. @@ -656,20 +732,41 @@ public sealed class GpuWorldState if (_dispatchingVisibilityTransitions) return; + List? failures = null; _dispatchingVisibilityTransitions = true; try { while (_visibilityTransitions.TryDequeue(out var transition)) { - LiveProjectionVisibilityChanged?.Invoke( - transition.ServerGuid, - transition.Visible); + Delegate[] subscribers = LiveProjectionVisibilityChanged? + .GetInvocationList() + ?? Array.Empty(); + for (int i = 0; i < subscribers.Length; i++) + { + try + { + ((Action)subscribers[i])( + transition.ServerGuid, + transition.Visible); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } } } finally { _dispatchingVisibilityTransitions = false; } + + if (failures is not null) + { + throw new AggregateException( + "One or more live projection visibility observers failed.", + failures); + } } // TEMP (#138-B): persistent guids currently present in the drawn flat diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index 7737cd37..62ca502a 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -159,6 +159,10 @@ public sealed class LiveEntityPresentationController : IDisposable internal bool HasActivePlacement(uint serverGuid) => _activePlacementGenerationByGuid.ContainsKey(serverGuid); + internal int ReadyOwnerCount => _readyGenerationByGuid.Count; + internal int DeferredShadowRestoreCount => _suspendedShadowGenerationByGuid.Count; + internal int ActivePlacementCount => _activePlacementGenerationByGuid.Count; + public void Forget(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 1830e541..91df4f68 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using System.Numerics; +using System.Runtime.ExceptionServices; namespace AcDream.App.World; @@ -131,6 +132,7 @@ public sealed class LiveEntityRecord public bool ResourcesRegistered { get; internal set; } public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyVisible { get; internal set; } + internal ulong ProjectionMutationVersion { get; set; } public bool WorldSpawnPublished { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } @@ -219,6 +221,11 @@ public sealed class LiveEntityRuntime private readonly Dictionary _guidByLocalId = new(); private readonly Dictionary _animationsByLocalId = new(); private readonly Dictionary _remoteMotionByGuid = new(); + private bool _isClearing; + private bool _isRegisteringResources; + private int _logicalTeardownDepth; + private ulong _sessionLifetimeVersion; + private readonly Dictionary _lifetimeMutationVersionByGuid = new(); private uint _rebucketingGuid; private uint _nextLocalEntityId; @@ -272,9 +279,19 @@ public sealed class LiveEntityRuntime public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) { + if (_isClearing || _isRegisteringResources) + { + throw new InvalidOperationException( + _isClearing + ? "A live entity cannot register while the session lifetime is clearing." + : "A live entity cannot register from inside atomic resource registration."); + } + InboundCreateResult result = _inbound.AcceptCreate(incoming); if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) return new LiveEntityRegistrationResult(result, null, false, false); + ulong sessionVersion = _sessionLifetimeVersion; + ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid); if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration) { @@ -293,21 +310,52 @@ public sealed class LiveEntityRuntime } bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old); + if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration) + ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence); + Exception? tearDownFailure = null; if (old is not null) { + _logicalTeardownDepth++; try { - TearDownRecord(old); + try + { + TearDownRecord(old); + } + catch (Exception error) + { + tearDownFailure = error; + } } - catch (Exception error) + finally { - tearDownFailure = error; + _logicalTeardownDepth--; } } - if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration) - ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence); + // Resource callbacks are arbitrary App integration code. Any nested + // create, accepted delete, or session reset advances this epoch. The + // outer packet is then superseded even when the nested action left no + // record/snapshot (delete/reset); reinstalling it would resurrect an + // incarnation after an accepted terminal event. + if (_sessionLifetimeVersion != sessionVersion + || CurrentLifetimeMutation(incoming.Guid) != operationVersion) + { + if (tearDownFailure is not null) + { + throw new AggregateException( + $"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its incoming replacement was superseded.", + tearDownFailure); + } + + return new LiveEntityRegistrationResult( + SupersededCreateResult(), + _recordsByGuid.GetValueOrDefault(incoming.Guid), + false, + replaced, + tearDownFailure); + } var record = new LiveEntityRecord(result.Snapshot); _recordsByGuid.Add(incoming.Guid, record); @@ -330,6 +378,11 @@ public sealed class LiveEntityRuntime LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World) { ArgumentNullException.ThrowIfNull(factory); + if (_isClearing || _logicalTeardownDepth != 0 || _isRegisteringResources) + { + throw new InvalidOperationException( + "A live entity cannot materialize inside an active logical-lifetime transition."); + } if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) return null; @@ -347,34 +400,42 @@ public sealed class LiveEntityRuntime _guidByLocalId.Add(localId, serverGuid); record.WorldEntity = entity; + _isRegisteringResources = true; 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); + _resources.Register(entity); + record.ResourcesRegistered = true; } - catch (Exception rollbackError) + catch (Exception registrationError) { - throw new AggregateException( - "Live entity resource registration and rollback both failed.", - registrationError, - rollbackError); + // Registration is an atomic logical-lifetime boundary. + // Give composite owners a symmetric rollback opportunity, + // then remove 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; } - finally - { - _guidByLocalId.Remove(localId); - record.WorldEntity = null; - record.ResourcesRegistered = false; - } - throw; + } + finally + { + _isRegisteringResources = false; } } @@ -401,18 +462,40 @@ public sealed class LiveEntityRuntime bool wasProjected = record.IsSpatiallyProjected; bool wasVisible = record.IsSpatiallyVisible; + ulong projectionOperation = ++record.ProjectionMutationVersion; // GpuWorldState reports an intermediate false/true pair while moving // between two loaded buckets. Suppress those implementation details // and publish only the final logical visibility edge. record.IsSpatiallyProjected = true; + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; _rebucketingGuid = serverGuid; try { - _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId); + try + { + _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId); + } + catch (AggregateException error) + { + // GpuWorldState has already committed the bucket move and + // drained every visibility observer before reporting their + // failures. Finish this runtime transaction before surfacing + // the notification error to the caller. + spatialNotificationFailure = error; + } } finally { - _rebucketingGuid = 0; + _rebucketingGuid = priorRebucketingGuid; + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; } bool visible = _spatial.IsLiveEntityVisible(serverGuid); record.IsSpatiallyVisible = visible; @@ -426,8 +509,22 @@ public sealed class LiveEntityRuntime record.CanonicalLandblockId = spatialCellOrLandblockId == 0 ? 0u : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + Exception? runtimeNotificationFailure = null; if (!wasProjected || wasVisible != visible) - ProjectionVisibilityChanged?.Invoke(record, visible); + { + try + { + PublishProjectionVisibilityChanged(record, visible); + } + catch (Exception error) + { + runtimeNotificationFailure = error; + } + } + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); return true; } @@ -441,7 +538,27 @@ public sealed class LiveEntityRuntime || record.WorldEntity is null) return false; - _spatial.RemoveLiveEntityProjection(serverGuid); + ulong projectionOperation = ++record.ProjectionMutationVersion; + Exception? spatialNotificationFailure = null; + try + { + _spatial.RemoveLiveEntityProjection(serverGuid); + } + catch (AggregateException error) + { + // The projection is already absent and GpuWorldState has already + // drained its observer queue. Complete canonical withdrawal below + // before reporting the notification failure. + spatialNotificationFailure = error; + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } // Usually GpuWorldState delivers the false edge synchronously above. // During a reentrant visibility callback it queues that edge until the // outer notification completes, so publish the logical withdrawal now; @@ -452,8 +569,22 @@ public sealed class LiveEntityRuntime _visibleWorldEntitiesByGuid.Remove(serverGuid); record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; + Exception? runtimeNotificationFailure = null; if (spatialEdgeWasDeferred) - ProjectionVisibilityChanged?.Invoke(record, false); + { + try + { + PublishProjectionVisibilityChanged(record, false); + } + catch (Exception error) + { + runtimeNotificationFailure = error; + } + } + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); return true; } @@ -479,31 +610,66 @@ public sealed class LiveEntityRuntime bool isLocalPlayer, Action? beforeTeardown = null) { + if (_isRegisteringResources) + { + throw new InvalidOperationException( + "A live entity cannot unregister from inside atomic resource registration."); + } if (!_inbound.TryDelete(delete, isLocalPlayer)) return false; + AdvanceLifetimeMutation(delete.Guid); ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); - List? failures = null; - try - { - beforeTeardown?.Invoke(); - } - catch (Exception error) - { - (failures ??= new List()).Add(error); - } + // Remove the accepted incarnation from canonical identity before + // invoking arbitrary callbacks. A callback may synchronously create a + // newer generation with the same server GUID; materialization waits + // until this teardown completes, and cleanup remains scoped to this + // captured record. + _recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record); - if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record)) + List? failures = null; + _logicalTeardownDepth++; + try { try { - TearDownRecord(record); + beforeTeardown?.Invoke(); } catch (Exception error) { (failures ??= new List()).Add(error); } + + if (record is not null) + { + try + { + TearDownRecord(record); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + } + finally + { + _logicalTeardownDepth--; + } + + try + { + // Persistence is GUID-scoped. End it only when the accepted + // delete left no replacement incarnation behind. + if (!_recordsByGuid.ContainsKey(delete.Guid)) + { + _spatial.ForgetLiveEntity(delete.Guid); + } + } + catch (Exception error) + { + (failures ??= new List()).Add(error); } if (failures is not null) @@ -905,26 +1071,54 @@ public sealed class LiveEntityRuntime public void Clear() { - List? failures = null; - foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray()) + if (_isClearing || _isRegisteringResources) { - try - { - TearDownRecord(record); - } - catch (Exception error) - { - (failures ??= new List()).Add(error); - } + throw new InvalidOperationException( + _isClearing + ? "Live entity session teardown is already in progress." + : "Live entity session teardown cannot begin inside atomic resource registration."); + } + + _isClearing = true; + _sessionLifetimeVersion++; + _lifetimeMutationVersionByGuid.Clear(); + List? failures = null; + try + { + foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray()) + { + // Remove canonical identity first. Registration is rejected + // during this terminal clear, so callbacks cannot leak an + // owner that is absent from the teardown snapshot. + if (!_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + || !ReferenceEquals(current, record)) + continue; + _recordsByGuid.Remove(record.ServerGuid); + + try + { + TearDownRecord(record); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + + _recordsByGuid.Clear(); + _materializedWorldEntitiesByGuid.Clear(); + _visibleWorldEntitiesByGuid.Clear(); + _guidByLocalId.Clear(); + _animationsByLocalId.Clear(); + _remoteMotionByGuid.Clear(); + ParentAttachments.Clear(); + _inbound.Clear(); + _spatial.ClearLiveEntityLifetimeState(); + } + finally + { + _isClearing = false; } - _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); @@ -941,6 +1135,49 @@ public sealed class LiveEntityRuntime record.RefreshDerivedState(refreshPosition); } + private static InboundCreateResult SupersededCreateResult() => new( + CreateObjectTimestampDisposition.StaleGeneration, + default, + null, + default); + + private ulong AdvanceLifetimeMutation(uint serverGuid) + { + ulong next = _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid) + 1UL; + _lifetimeMutationVersionByGuid[serverGuid] = next; + return next; + } + + private ulong CurrentLifetimeMutation(uint serverGuid) => + _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid); + + private bool IsCurrentProjectionOperation( + uint serverGuid, + LiveEntityRecord record, + ulong projectionOperation) => + _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && record.ProjectionMutationVersion == projectionOperation; + + private static void ThrowAfterCommittedProjectionChange( + uint serverGuid, + Exception? spatialNotificationFailure, + Exception? runtimeNotificationFailure) + { + if (spatialNotificationFailure is not null + && runtimeNotificationFailure is not null) + { + throw new AggregateException( + $"Projection change for live entity 0x{serverGuid:X8} committed, but spatial and runtime observers failed.", + spatialNotificationFailure, + runtimeNotificationFailure); + } + + Exception? failure = spatialNotificationFailure ?? runtimeNotificationFailure; + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + private void ClearWorldCell(uint guid) { if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) @@ -968,6 +1205,13 @@ public sealed class LiveEntityRuntime private void OnSpatialVisibilityChanged(uint serverGuid, bool visible) { + // GpuWorldState serializes re-entrant visibility callbacks. A callback + // may delete an incarnation and materialize the same server GUID before + // an older queued edge drains. Apply an edge only while it still + // matches current spatial truth; otherwise it belongs to the displaced + // projection and must not mutate the replacement generation. + if (_spatial.IsLiveEntityVisible(serverGuid) != visible) + return; if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is not { } entity) return; @@ -976,7 +1220,32 @@ public sealed class LiveEntityRuntime record.IsSpatiallyVisible = visible; RefreshPresentation(record); if (_rebucketingGuid != serverGuid && wasVisible != visible) - ProjectionVisibilityChanged?.Invoke(record, visible); + PublishProjectionVisibilityChanged(record, visible); + } + + private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible) + { + Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList() + ?? Array.Empty(); + List? failures = null; + for (int i = 0; i < subscribers.Length; i++) + { + try + { + ((Action)subscribers[i])(record, visible); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + + if (failures is not null) + { + throw new AggregateException( + $"One or more projection observers failed for live entity 0x{record.ServerGuid:X8}.", + failures); + } } private void RefreshPresentation(LiveEntityRecord record) @@ -1017,16 +1286,32 @@ public sealed class LiveEntityRuntime if (record.WorldEntity is { } entity) { - RunCleanup(() => _spatial.RemoveLiveEntityProjection(record.ServerGuid)); + RunCleanup(() => _spatial.RemoveLiveEntityProjection(entity)); if (record.ResourcesRegistered) RunCleanup(() => _resources.Unregister(entity)); _guidByLocalId.Remove(entity.Id); - _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); - _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + if (_materializedWorldEntitiesByGuid.TryGetValue( + record.ServerGuid, + out WorldEntity? materialized) + && ReferenceEquals(materialized, entity)) + { + _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); + } + if (_visibleWorldEntitiesByGuid.TryGetValue( + record.ServerGuid, + out WorldEntity? visible) + && ReferenceEquals(visible, entity)) + { + _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + } _animationsByLocalId.Remove(entity.Id); } - _remoteMotionByGuid.Remove(record.ServerGuid); + if (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote) + && ReferenceEquals(remote, record.RemoteMotionRuntime)) + { + _remoteMotionByGuid.Remove(record.ServerGuid); + } record.AnimationRuntime = null; record.RemoteMotionRuntime = null; record.RequiresRemotePlacementRuntime = false; diff --git a/src/AcDream.Core/Lighting/LightingHookSink.cs b/src/AcDream.Core/Lighting/LightingHookSink.cs index 047f8c20..dec5ab9b 100644 --- a/src/AcDream.Core/Lighting/LightingHookSink.cs +++ b/src/AcDream.Core/Lighting/LightingHookSink.cs @@ -41,6 +41,15 @@ public sealed class LightingHookSink : IAnimationHookSink /// public event Action? OwnerLightingChanged; + /// + /// Diagnostic ownership counts for lifecycle verification. The light + /// manager's registered-light count alone cannot reveal a stale owner + /// lighting latch or pose-tracking list. + /// + public int OwnedLightOwnerCount => _byOwner.Count; + public int PoseTrackedOwnerCount => _trackedByOwner.Count; + public int RetainedOwnerStateCount => _enabledByOwner.Count; + public LightingHookSink(LightManager lights, IEntityEffectPoseSource poses) { _lights = lights ?? throw new System.ArgumentNullException(nameof(lights)); diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 6e972cec..e4be501b 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -540,6 +540,18 @@ public sealed class ShadowObjectRegistry public int TotalRegistered => _entityToCells.Count; + /// + /// Total logical shadow registrations, including dynamic live objects + /// suspended with zero cell rows while their landblock is unavailable. + /// Lifecycle stress gates must inspect this value as well as + /// so a retained collision payload cannot + /// survive its owning live-object incarnation unnoticed. + /// + public int RetainedRegistrationCount => _entityReg.Count; + + /// Suspended logical registrations awaiting spatial re-entry. + public int SuspendedRegistrationCount => _suspendedEntities.Count; + /// /// Debug: enumerate every registered ShadowEntry (deduplicated across cells). /// Single-shape entities return one entry per shape; multi-part entities diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index ce23f3ac..1a0d9cff 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -42,6 +42,18 @@ public sealed class ParticleHookSink : IAnimationHookSink public Action? DiagnosticSink { get; set; } + /// + /// Diagnostic ownership counts used by lifecycle stress gates. These count + /// the sink's retained bookkeeping, not only emitters still present in the + /// particle simulator, so teardown tests can detect a stale logical ID or + /// per-owner handle bag after the underlying emitter has gone away. + /// + public int ActiveBindingCount => _bindingsByHandle.Count; + public int LogicalEmitterCount => _handlesByKey.Count; + public int TrackedOwnerCount => _handlesByEntity.Count; + public int RenderPassOwnerCount => _renderPassByEntity.Count; + public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count; + private void OnEmitterDied(int handle) { if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding)) diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs index 2f475914..353e51d4 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -55,6 +55,7 @@ public sealed class PhysicsScriptRunner public int ActiveOwnerCount => _owners.Count; public int ScheduledCallPesCount => _delayedCalls.Count; + public int OwnerAnchorCount => _ownerAnchors.Count; public bool DiagEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1"; diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index 0e1d8446..4b46cea9 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -432,6 +432,23 @@ public sealed class EntityEffectControllerTests Assert.NotEqual(firstLocalId, replacementLocalId); } + [Fact] + public void DelayedOldEffectTeardown_DoesNotClearReadyReplacementGeneration() + { + var fixture = new Fixture(); + WorldEntity old = fixture.ReadyLive(Guid, generation: 1); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord oldRecord)); + + WorldEntity replacement = fixture.ReadyLive(Guid, generation: 2); + fixture.Controller.OnLiveEntityUnregistered(oldRecord); + + Assert.NotEqual(old.Id, replacement.Id); + Assert.Equal(1, fixture.Controller.ReadyOwnerCount); + fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); + fixture.Runner.Tick(0.0); + Assert.Equal(replacement.Id, Assert.Single(fixture.Sink.Calls).EntityId); + } + [Fact] public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs new file mode 100644 index 00000000..c27a632e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class EntitySpawnAdapterLifetimeTests +{ + [Fact] + public void NinetySixLiveOwners_DeleteAndGuidReuse_BalanceEveryMeshReference() + { + const int ownerCount = 96; + const int reuseCount = 32; + const uint guidBase = 0x74000000u; + var meshes = new RefCountingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new NullTextureCache(), + _ => MakeSequencer(), + meshes); + + for (int i = 0; i < ownerCount; i++) + { + uint guid = guidBase + (uint)i; + WorldEntity entity = MakeEntity((uint)i + 1u, guid); + entity.MeshRefs = + [ + new MeshRef(0x01000001u, Matrix4x4.Identity), + new MeshRef(0x01000001u, Matrix4x4.Identity), + ]; + entity.ApplyAppearance( + entity.MeshRefs, + entity.PaletteOverride, + [new PartOverride(0, 0x01001000u + (uint)i)]); + Assert.NotNull(adapter.OnCreate(entity)); + } + + Assert.Equal(ownerCount * 2, meshes.TotalReferenceCount); + for (int i = 0; i < reuseCount; i++) + { + uint guid = guidBase + (uint)i; + adapter.OnRemove(guid); + Assert.Null(adapter.GetState(guid)); + + WorldEntity replacement = MakeEntity(1000u + (uint)i, guid); + replacement.ApplyAppearance( + replacement.MeshRefs, + replacement.PaletteOverride, + [new PartOverride(0, 0x01002000u + (uint)i)]); + Assert.NotNull(adapter.OnCreate(replacement)); + } + + for (int i = 0; i < ownerCount; i++) + { + uint guid = guidBase + (uint)i; + adapter.OnRemove(guid); + Assert.Null(adapter.GetState(guid)); + } + + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Empty(meshes.ReferenceCounts); + Assert.Equal(meshes.IncrementCount, meshes.DecrementCount); + } + + private static WorldEntity MakeEntity(uint id, uint serverGuid) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000001u, Matrix4x4.Identity)], + }; + + private static AnimationSequencer MakeSequencer() => + new(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed class NullTextureCache : ITextureCachePerInstance + { + public uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride) => 1u; + } + + private sealed class RefCountingMeshAdapter : IWbMeshAdapter + { + public Dictionary ReferenceCounts { get; } = new(); + public int IncrementCount { get; private set; } + public int DecrementCount { get; private set; } + public int TotalReferenceCount => ReferenceCounts.Values.Sum(); + + public void IncrementRefCount(ulong id) + { + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + IncrementCount++; + } + + public void DecrementRefCount(ulong id) + { + Assert.True(ReferenceCounts.TryGetValue(id, out int count)); + Assert.True(count > 0); + if (count == 1) + ReferenceCounts.Remove(id); + else + ReferenceCounts[id] = count - 1; + DecrementCount++; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs new file mode 100644 index 00000000..2ff08029 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -0,0 +1,139 @@ +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class GpuWorldStateVisibilityTests +{ + [Fact] + public void ThrowingObserver_DoesNotStrandLaterSubscribersOrQueuedOwnerEdges() + { + const uint landblock = 0x0101FFFFu; + WorldEntity first = Entity(1u, 0x70000001u); + WorldEntity second = Entity(2u, 0x70000002u); + var state = new GpuWorldState(); + state.PlaceLiveEntityProjection(landblock, first); + state.PlaceLiveEntityProjection(landblock, second); + var observed = new List<(uint Guid, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (_, _) => + throw new InvalidOperationException("fixture observer failure"); + state.LiveProjectionVisibilityChanged += (guid, visible) => + observed.Add((guid, visible)); + + AggregateException error = Assert.Throws(() => + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty()))); + + Assert.Equal(2, error.InnerExceptions.Count); + Assert.Equal( + [(first.ServerGuid, true), (second.ServerGuid, true)], + observed.OrderBy(edge => edge.Guid).ToArray()); + Assert.True(state.IsLiveEntityVisible(first.ServerGuid)); + Assert.True(state.IsLiveEntityVisible(second.ServerGuid)); + Assert.Equal(0, state.PendingVisibilityTransitionCount); + Assert.Equal(2, state.Entities.Count); + } + + [Fact] + public void ThrowingRuntimeObserver_DoesNotSkipOtherOwnersOrPresentationSubscribers() + { + const uint landblock = 0x0101FFFFu; + const uint cell = 0x01010001u; + var state = new GpuWorldState(); + var runtime = new LiveEntityRuntime( + state, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + WorldSession.EntitySpawn first = Spawn(0x70000011u, cell); + WorldSession.EntitySpawn second = Spawn(0x70000012u, cell); + runtime.RegisterLiveEntity(first); + runtime.RegisterLiveEntity(second); + runtime.MaterializeLiveEntity(first.Guid, cell, id => Entity(id, first.Guid)); + runtime.MaterializeLiveEntity(second.Guid, cell, id => Entity(id, second.Guid)); + var observed = new List<(uint Guid, bool Visible)>(); + runtime.ProjectionVisibilityChanged += (_, _) => + throw new InvalidOperationException("fixture presentation failure"); + runtime.ProjectionVisibilityChanged += (record, visible) => + observed.Add((record.ServerGuid, visible)); + + AggregateException error = Assert.Throws(() => + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty()))); + + Assert.Equal(2, error.Flatten().InnerExceptions.Count); + Assert.Equal( + [(first.Guid, true), (second.Guid, true)], + observed.OrderBy(edge => edge.Guid).ToArray()); + Assert.True(runtime.TryGetRecord(first.Guid, out LiveEntityRecord firstRecord)); + Assert.True(runtime.TryGetRecord(second.Guid, out LiveEntityRecord secondRecord)); + Assert.True(firstRecord.IsSpatiallyVisible); + Assert.True(secondRecord.IsSpatiallyVisible); + Assert.Equal(0, state.PendingVisibilityTransitionCount); + } + + private static WorldEntity Entity(uint id, uint guid) => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "visibility fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs new file mode 100644 index 00000000..c8af583c --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -0,0 +1,1049 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +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.Enums; +using DatReaderWriter.Types; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Tests.World; + +/// +/// Step 9 deterministic whole-lifecycle gate for the canonical live-object, +/// projectile, and DAT-effect ownership graph. It deliberately exercises +/// enough simultaneous owners to expose collection mutation and incomplete +/// teardown that single-object conformance tests cannot reveal. +/// +public sealed class LiveEntityLifecycleStressTests +{ + private const int OwnerCount = 96; + private const int ReusedOwnerCount = 32; + private const uint GuidBase = 0x72000000u; + private const uint CellA = 0x01010001u; + private const uint CellB = 0x01020001u; + private const uint ScriptDid = 0x3300AA01u; + private const uint EmitterDid = 0x3200AA01u; + private const uint SetupDid = 0x02000124u; + private const uint RecallMotion = 0x10000153u; + + private static readonly PhysicsStateFlags MissileState = + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Lighting + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + + [Fact] + public void NinetySixOwners_ChurnDeleteResetAndGuidReuse_LeaveNoRuntimeResources() + { + using var fixture = new Fixture(); + var oldLocalIds = new List(OwnerCount + ReusedOwnerCount); + var lifetimeRecords = new List(OwnerCount + ReusedOwnerCount); + + for (int i = 0; i < OwnerCount; i++) + { + uint guid = GuidBase + (uint)i; + fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid)); + LiveEntityRecord record = fixture.Spawn(guid, generation: 1, i); + lifetimeRecords.Add(record); + oldLocalIds.Add(record.LocalEntityId!.Value); + + // Repeated direct network PES plays append to the same owner's + // retail FIFO. They must neither deduplicate nor create another + // logical owner. The separate recall test below drives the exact + // recall motion and CallPES chain. + if (i % 12 == 0) + { + fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid)); + fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid)); + } + } + + fixture.Runner.Tick(0.0); + fixture.Particles.Tick(0.01f); + fixture.Projectiles.Tick( + currentTime: 0.05, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + + Assert.Equal(OwnerCount, fixture.Runtime.Count); + Assert.Equal(OwnerCount, fixture.Runtime.MaterializedCount); + Assert.Equal(OwnerCount, fixture.RenderOwners.Count); + Assert.Equal(OwnerCount, fixture.Projectiles.Count); + Assert.Equal(OwnerCount, fixture.Effects.ReadyOwnerCount); + Assert.Equal(0, fixture.Effects.PendingPacketCount); + Assert.Equal(OwnerCount, fixture.Runner.ActiveOwnerCount); + Assert.True(fixture.Runner.ActiveScriptCount > OwnerCount); + Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount); + Assert.Equal(OwnerCount, fixture.ParticleSink.ActiveBindingCount); + Assert.Equal(OwnerCount, fixture.ParticleSink.LogicalEmitterCount); + Assert.Equal(OwnerCount, fixture.ParticleSink.TrackedOwnerCount); + Assert.Equal(OwnerCount, fixture.ParticleSink.RenderPassOwnerCount); + Assert.Equal(OwnerCount, fixture.Poses.Count); + Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount); + Assert.Equal(OwnerCount, fixture.LiveLights.TrackedOwnerCount); + Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount); + Assert.Equal(OwnerCount, fixture.Lighting.OwnedLightOwnerCount); + Assert.Equal(OwnerCount, fixture.Lighting.PoseTrackedOwnerCount); + Assert.Equal(OwnerCount, fixture.Lighting.RetainedOwnerStateCount); + Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + + // Loaded -> pending -> loaded -> pending -> loaded churn keeps the + // same projections and all logical owners; only spatial presentation + // changes. This is the streaming race that previously replayed create. + for (int i = 0; i < OwnerCount; i += 2) + Assert.True(fixture.Runtime.RebucketLiveEntity(GuidBase + (uint)i, CellB)); + Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount); + Assert.Equal(OwnerCount, fixture.RenderOwners.Count); + Assert.Equal(OwnerCount / 2, fixture.ParticleSink.HiddenPresentationOwnerCount); + Assert.Equal(OwnerCount / 2, fixture.Lights.RegisteredCount); + Assert.Equal(OwnerCount / 2, fixture.LiveLights.PresentedOwnerCount); + + fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount); + Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount); + Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount); + fixture.Spatial.RemoveLandblock(0x0102FFFFu); + Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount); + fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount); + Assert.Equal(OwnerCount, fixture.RenderOwners.Count); + Assert.Equal(OwnerCount, fixture.Projectiles.Count); + Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount); + Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount); + + // End one third rapidly, then reuse those exact server GUIDs with a + // newer instance timestamp. No queue, body, emitter, light, or shadow + // from generation 1 may attach to generation 2. + for (int i = 0; i < ReusedOwnerCount; i++) + { + uint guid = GuidBase + (uint)i; + Assert.True(fixture.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + } + Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Runtime.Count); + Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.RenderOwners.Count); + Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Particles.ActiveEmitterCount); + Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Lights.RegisteredCount); + Assert.Equal( + OwnerCount - ReusedOwnerCount, + fixture.Engine.ShadowObjects.RetainedRegistrationCount); + + for (int i = 0; i < ReusedOwnerCount; i++) + { + uint guid = GuidBase + (uint)i; + fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid)); + LiveEntityRecord replacement = fixture.Spawn(guid, generation: 2, i); + lifetimeRecords.Add(replacement); + oldLocalIds.Add(replacement.LocalEntityId!.Value); + } + fixture.Runner.Tick(1.0); + fixture.Particles.Tick(0.01f); + + Assert.Equal(OwnerCount, fixture.Runtime.Count); + Assert.Equal(OwnerCount, fixture.RenderOwners.Count); + Assert.Equal(OwnerCount, fixture.Projectiles.Count); + Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount); + Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount); + Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + + // Mix accepted deletes with a session reset, and leave packets for + // never-created owners pending so the network reset path is covered. + for (int i = 0; i < OwnerCount; i += 2) + { + uint guid = GuidBase + (uint)i; + ushort generation = i < ReusedOwnerCount ? (ushort)2 : (ushort)1; + Assert.True(fixture.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, generation), + isLocalPlayer: false)); + } + for (int i = 0; i < 8; i++) + { + fixture.Effects.HandleDirect(new PlayPhysicsScript( + 0x73000000u + (uint)i, + ScriptDid)); + } + Assert.Equal(8, fixture.Effects.PendingPacketCount); + + fixture.Runtime.Clear(); + fixture.Effects.ClearNetworkState(); + fixture.Particles.Tick(0.01f); + + Assert.Equal(0, fixture.Runtime.Count); + Assert.Equal(0, fixture.Runtime.MaterializedCount); + Assert.Empty(fixture.RenderOwners); + Assert.Equal(fixture.RenderRegisterCount, fixture.RenderUnregisterCount); + Assert.Equal(0, fixture.Projectiles.Count); + Assert.Equal(0, fixture.Effects.ReadyOwnerCount); + Assert.Equal(0, fixture.Effects.PendingPacketCount); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.Equal(0, fixture.Runner.ActiveScriptCount); + Assert.Equal(0, fixture.Runner.ScheduledCallPesCount); + Assert.Equal(0, fixture.Runner.OwnerAnchorCount); + Assert.Equal(0, fixture.Particles.ActiveEmitterCount); + Assert.Equal(0, fixture.Particles.ActiveParticleCount); + Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount); + Assert.Equal(0, fixture.ParticleSink.LogicalEmitterCount); + Assert.Equal(0, fixture.ParticleSink.TrackedOwnerCount); + Assert.Equal(0, fixture.ParticleSink.RenderPassOwnerCount); + Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount); + Assert.Equal(0, fixture.Poses.Count); + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Equal(0, fixture.Lighting.OwnedLightOwnerCount); + Assert.Equal(0, fixture.Lighting.PoseTrackedOwnerCount); + Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount); + Assert.Equal(0, fixture.LiveLights.TrackedOwnerCount); + Assert.Equal(0, fixture.LiveLights.PresentedOwnerCount); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount); + Assert.Equal(0, fixture.Spatial.PendingBucketCount); + Assert.Equal(0, fixture.Spatial.PendingRescueCount); + Assert.Equal(0, fixture.Spatial.PersistentGuidCount); + Assert.Equal(0, fixture.Spatial.PendingVisibilityTransitionCount); + Assert.DoesNotContain(fixture.Spatial.Entities, entity => entity.ServerGuid != 0); + Assert.All(lifetimeRecords, record => + { + Assert.Null(record.PhysicsBody); + Assert.Null(record.ProjectileRuntime); + Assert.Null(record.EffectProfile); + Assert.Null(record.WorldEntity); + }); + + // A stale retained shadow payload would resurrect here despite the + // visible-cell count already reading zero. + foreach (uint localId in oldLocalIds) + { + fixture.Engine.ShadowObjects.UpdatePosition( + localId, + new Vector3(20f, 20f, 10f), + Quaternion.Identity, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: CellA); + } + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + + // Session teardown is idempotent and cannot recreate bookkeeping. + fixture.Runtime.Clear(); + fixture.Effects.ClearNetworkState(); + Assert.Equal(0, fixture.Runtime.Count); + Assert.Equal(0, fixture.Effects.PendingPacketCount); + } + + [Fact] + public void RepeatedRetailRecallMotion_HiddenTeleportUnhide_DrainsEveryPresentationOwner() + { + using var fixture = new RecallPortalFixture(); + + for (int cycle = 0; cycle < 12; cycle++) + { + fixture.PlayRecallMotion(cycle + 1.0); + Assert.Equal(1, fixture.Particles.ActiveEmitterCount); + Assert.Equal(1, fixture.ParticleSink.ActiveBindingCount); + + fixture.ApplyHidden(); + Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount); + + fixture.BeginDeferredTeleport(); + Assert.Equal(1, fixture.Teleport.PendingPlacementCount); + Assert.Equal(1, fixture.Presentation.ActivePlacementCount); + + fixture.LoadDestination(); + Assert.Equal(0, fixture.Teleport.PendingPlacementCount); + Assert.Equal(0, fixture.Presentation.ActivePlacementCount); + Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount); + + fixture.ApplyVisible(); + Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + fixture.UnloadPriorLandblock(); + } + + Assert.Equal(12, fixture.TypedScripts.Count(type => + type == LiveEntityPresentationController.HiddenScriptType)); + Assert.Equal(12, fixture.TypedScripts.Count(type => + type == LiveEntityPresentationController.UnHideScriptType)); + Assert.Equal(12, fixture.RecallHookCount); + + fixture.Clear(); + + Assert.Equal(0, fixture.Runtime.Count); + Assert.Equal(0, fixture.Effects.ReadyOwnerCount); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.Equal(0, fixture.Runner.OwnerAnchorCount); + Assert.Equal(0, fixture.Particles.ActiveEmitterCount); + Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount); + Assert.Equal(0, fixture.Poses.Count); + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal(0, fixture.Teleport.PendingPlacementCount); + Assert.Equal(0, fixture.Presentation.ReadyOwnerCount); + Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount); + Assert.Equal(0, fixture.Presentation.ActivePlacementCount); + Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount); + Assert.Equal(0, fixture.Spatial.PendingBucketCount); + } + + private sealed class Fixture : IDisposable + { + private readonly Dictionary _scripts = new(); + private EntityEffectController? _effects; + private LiveEntityLightController? _liveLights; + + internal Fixture() + { + Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + AddEmptyPhysicsLandblock(Engine, 0x0101FFFFu, 0f, 0f); + AddEmptyPhysicsLandblock(Engine, 0x0102FFFFu, 0f, 192f); + + var emitterRegistry = new EmitterDescRegistry(); + emitterRegistry.Register(PersistentEmitter()); + Particles = new ParticleSystem(emitterRegistry, new Random(42)); + ParticleSink = new ParticleHookSink(Particles, Poses); + Lighting = new LightingHookSink(Lights, Poses); + Router.Register(ParticleSink); + Router.Register(Lighting); + + _scripts.Add(ScriptDid, PersistentScript()); + Runner = new PhysicsScriptRunner( + did => _scripts.TryGetValue(did, out DatPhysicsScript? script) + ? script + : null, + Router, + randomUnit: () => 0.5, + canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); + + Runtime = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle( + entity => + { + Assert.True(RenderOwners.Add(entity.Id)); + RenderRegisterCount++; + Poses.PublishMeshRefs(entity); + ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene); + }, + entity => + { + Runner.StopAllForEntity(entity.Id); + ParticleSink.StopAllForEntity(entity.Id, fadeOut: false); + Poses.Remove(entity.Id); + Assert.True(RenderOwners.Remove(entity.Id)); + RenderUnregisterCount++; + }), + record => + { + var cleanups = new List + { + () => _effects?.OnLiveEntityUnregistered(record), + }; + if (record.WorldEntity is { } entity) + { + cleanups.Add(() => Engine.ShadowObjects.Deregister(entity.Id)); + cleanups.Add(() => _liveLights?.Forget(entity.Id)); + } + LiveEntityTeardown.Run(cleanups); + }); + + Effects = new EntityEffectController( + Runtime, + Runner, + new PhysicsScriptTableResolver(_ => null), + Poses); + _effects = Effects; + Router.Register(Effects); + Projectiles = new ProjectileController(Runtime, Engine); + LiveLights = new LiveEntityLightController( + Runtime, + Poses, + Lighting, + setupId => setupId == SetupDid ? Setup : null); + _liveLights = LiveLights; + Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; + } + + internal GpuWorldState Spatial { get; } = new(); + internal PhysicsEngine Engine { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal AnimationHookRouter Router { get; } = new(); + internal ParticleSystem Particles { get; } + internal ParticleHookSink ParticleSink { get; } + internal LightManager Lights { get; } = new(); + internal LightingHookSink Lighting { get; } + internal PhysicsScriptRunner Runner { get; } + internal LiveEntityRuntime Runtime { get; } + internal EntityEffectController Effects { get; } + internal ProjectileController Projectiles { get; } + internal LiveEntityLightController LiveLights { get; } + internal Setup Setup { get; } = ProjectileSetup(); + internal HashSet RenderOwners { get; } = new(); + internal int RenderRegisterCount { get; private set; } + internal int RenderUnregisterCount { get; private set; } + + internal LiveEntityRecord Spawn(uint guid, ushort generation, int ordinal) + { + Vector3 position = new( + 8f + (ordinal % 12) * 6f, + 8f + (ordinal / 12) * 6f, + 10f); + WorldSession.EntitySpawn spawn = MissileSpawn(guid, generation, position); + LiveEntityRegistrationResult registration = Runtime.RegisterLiveEntity(spawn); + LiveEntityRecord record = Assert.IsType(registration.Record); + Runtime.SetEffectProfile( + guid, + EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value)); + WorldEntity entity = Assert.IsType(Runtime.MaterializeLiveEntity( + guid, + CellA, + localId => new WorldEntity + { + Id = localId, + ServerGuid = guid, + SourceGfxObjOrSetupId = SetupDid, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = CellA, + })); + Assert.True(Effects.OnLiveEntityReady(guid)); + Assert.True(Projectiles.TryBind(record, Setup, 0.0, 1, 1)); + + Engine.ShadowObjects.Register( + entity.Id, + SetupDid, + entity.Position, + entity.Rotation, + radius: 0.1f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + state: (uint)MissileState, + seedCellId: CellA, + isStatic: false); + Assert.True(LiveLights.Register(guid)); + return record; + } + + private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) + { + if (record.WorldEntity is { } entity) + ParticleSink.SetEntityPresentationVisible(entity.Id, visible); + } + + public void Dispose() + { + Runtime.Clear(); + Effects.ClearNetworkState(); + Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; + LiveLights.Dispose(); + } + } + + private sealed class RecallPortalFixture : IDisposable + { + private const uint Guid = 0x75000001u; + private const uint CellOne = 0x01010001u; + private const uint CellTwo = 0x01020001u; + private const uint RecallScriptDid = 0x3300BB01u; + private const uint IdleAnimationDid = 0x0300BB01u; + private const uint RecallAnimationDid = 0x0300BB02u; + private const uint Style = 0x8000003Du; + private const uint ReadyMotion = 0x41000003u; + + private readonly AnimationHookFrameQueue _hookQueue; + private readonly TestRemoteMotion _remote = new(); + private uint _currentCell = CellOne; + private uint _destinationCell; + private ushort _stateSequence = 1; + private bool _cleared; + + internal RecallPortalFixture() + { + Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var emitterRegistry = new EmitterDescRegistry(); + emitterRegistry.Register(PersistentEmitter()); + Particles = new ParticleSystem(emitterRegistry, new Random(73)); + ParticleSink = new ParticleHookSink(Particles, Poses); + Router.Register(ParticleSink); + + DatPhysicsScript recallScript = RecallScript(); + Runner = new PhysicsScriptRunner( + did => did == RecallScriptDid ? recallScript : null, + Router, + randomUnit: () => 0.5); + + EntityEffectController? effects = null; + LiveEntityPresentationController? presentation = null; + RemoteTeleportController? teleport = null; + Runtime = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle( + entity => + { + Poses.PublishMeshRefs(entity); + ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene); + }, + entity => + { + Runner.StopAllForEntity(entity.Id); + ParticleSink.StopAllForEntity(entity.Id, fadeOut: false); + Poses.Remove(entity.Id); + }), + record => + { + effects?.OnLiveEntityUnregistered(record); + presentation?.Forget(record); + teleport?.Forget(record); + if (record.WorldEntity is { } entity) + Engine.ShadowObjects.Deregister(entity.Id); + }); + Effects = effects = new EntityEffectController( + Runtime, + Runner, + new PhysicsScriptTableResolver(_ => null), + Poses); + Router.Register(Effects); + + WorldSession.EntitySpawn spawn = RecallSpawn(Guid, CellOne); + Runtime.RegisterLiveEntity(spawn); + Runtime.SetEffectProfile( + Guid, + EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value)); + Entity = Runtime.MaterializeLiveEntity( + Guid, + CellOne, + localId => new WorldEntity + { + Id = localId, + ServerGuid = Guid, + SourceGfxObjOrSetupId = SetupDid, + Position = new Vector3(10f, 10f, 10f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = CellOne, + })!; + Assert.True(Effects.OnLiveEntityReady(Guid)); + + _remote.Body.SnapToCell(CellOne, Entity.Position, Entity.Position); + _remote.CellId = CellOne; + Runtime.SetRemoteMotionRuntime(Guid, _remote); + Engine.ShadowObjects.Register( + Entity.Id, + SetupDid, + Entity.Position, + Entity.Rotation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x0101FFFFu, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: CellOne, + isStatic: false); + + Presentation = presentation = new LiveEntityPresentationController( + Runtime, + Engine.ShadowObjects, + (_, type, _) => + { + TypedScripts.Add(type); + return true; + }, + liveCenter: () => (1, 1)); + Assert.True(Presentation.OnLiveEntityReady(Guid)); + + Teleport = teleport = new RemoteTeleportController( + Engine, + Runtime, + (_, _) => (0.48f, 1.835f), + (position, _) => position, + (_, _, _) => { }, + (guid, generation, defer) => + Presentation.CompleteAuthoritativePlacement(guid, generation, defer), + (guid, generation) => + Presentation.BeginAuthoritativePlacement(guid, generation), + resolvePlacement: (position, cell, _, _, _, _) => new ResolveResult( + position, + cell, + IsOnGround: true, + InContact: true, + OnWalkable: true, + ContactPlane: new Plane(Vector3.UnitZ, 0f), + ContactPlaneCellId: cell)); + + Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; + _hookQueue = new AnimationHookFrameQueue(Router, Poses); + } + + internal GpuWorldState Spatial { get; } = new(); + internal PhysicsEngine Engine { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal AnimationHookRouter Router { get; } = new(); + internal ParticleSystem Particles { get; } + internal ParticleHookSink ParticleSink { get; } + internal PhysicsScriptRunner Runner { get; } + internal LiveEntityRuntime Runtime { get; } + internal EntityEffectController Effects { get; } + internal WorldEntity Entity { get; } + internal LiveEntityPresentationController Presentation { get; } + internal RemoteTeleportController Teleport { get; } + internal List TypedScripts { get; } = []; + internal int RecallHookCount { get; private set; } + + internal void PlayRecallMotion(double gameTime) + { + AnimationSequencer recallSequencer = BuildRecallSequencer(); + recallSequencer.SetCycle(Style, ReadyMotion); + recallSequencer.PlayAction(RecallMotion); + recallSequencer.Advance(0.15f); + IReadOnlyList hooks = recallSequencer.ConsumePendingHooks(); + RecallHookCount += hooks.Count(hook => hook is CallPESHook call + && (uint)call.PES == RecallScriptDid); + _hookQueue.Capture(Entity.Id, recallSequencer, hooks); + _hookQueue.Drain(); + Runner.Tick(gameTime); + Particles.Tick(0.01f); + } + + internal void ApplyHidden() + { + Assert.True(Runtime.TryApplyState( + new SetState.Parsed( + Guid, + (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions), + InstanceSequence: 1, + StateSequence: ++_stateSequence), + out _, + out _)); + Assert.True(Presentation.OnStateAccepted(Guid)); + } + + internal void BeginDeferredTeleport() + { + _destinationCell = _currentCell == CellOne ? CellTwo : CellOne; + Assert.False(Spatial.IsLoaded((_destinationCell & 0xFFFF0000u) | 0xFFFFu)); + Teleport.BeginPlacement(Guid, generation: 1); + Assert.True(Runtime.RebucketLiveEntity(Guid, _destinationCell)); + RemoteTeleportController.Result result = Teleport.TryApply( + _remote, + Entity, + new Vector3(Entity.Position.X + 3f, Entity.Position.Y, Entity.Position.Z), + _destinationCell, + Entity.Position, + Quaternion.Identity, + gameTime: _stateSequence, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 1); + Assert.True(result.Applied); + Assert.False(result.ContactResolved); + } + + internal void LoadDestination() + { + Spatial.AddLandblock(EmptyLandblock( + (_destinationCell & 0xFFFF0000u) | 0xFFFFu)); + } + + internal void ApplyVisible() + { + Assert.True(Runtime.TryApplyState( + new SetState.Parsed( + Guid, + (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + StateSequence: ++_stateSequence), + out _, + out _)); + Assert.True(Presentation.OnStateAccepted(Guid)); + } + + internal void UnloadPriorLandblock() + { + uint priorLandblock = (_currentCell & 0xFFFF0000u) | 0xFFFFu; + Spatial.RemoveLandblock(priorLandblock); + _currentCell = _destinationCell; + } + + internal void Clear() + { + if (_cleared) + return; + _cleared = true; + Runtime.Clear(); + Effects.ClearNetworkState(); + _hookQueue.Clear(); + Particles.Tick(0.01f); + } + + public void Dispose() + { + Clear(); + Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; + Teleport.Dispose(); + Presentation.Dispose(); + } + + private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) + { + if (record.WorldEntity is { } entity) + ParticleSink.SetEntityPresentationVisible(entity.Id, visible); + } + + private static AnimationSequencer BuildRecallSequencer() + { + var setup = new Setup(); + setup.Parts.Add(0x01000001u); + setup.DefaultScale.Add(Vector3.One); + + var table = new MotionTable + { + DefaultStyle = (DRWMotionCommand)Style, + }; + table.StyleDefaults[(DRWMotionCommand)Style] = + (DRWMotionCommand)ReadyMotion; + int readyKey = unchecked((int)((Style << 16) | (ReadyMotion & 0xFFFFFFu))); + table.Cycles[readyKey] = MotionData(IdleAnimationDid); + var links = new MotionCommandData(); + links.MotionData[unchecked((int)RecallMotion)] = MotionData(RecallAnimationDid); + table.Links[readyKey] = links; + + Animation idle = Animation(frames: 2); + Animation recall = Animation(frames: 2); + recall.PartFrames[0].Hooks.Add(new CallPESHook + { + Direction = AnimationHookDir.Forward, + PES = RecallScriptDid, + Pause = 0f, + }); + var loader = new RecallAnimationLoader(); + loader.Add(IdleAnimationDid, idle); + loader.Add(RecallAnimationDid, recall); + return new AnimationSequencer(setup, table, loader); + } + + private static MotionData MotionData(uint animationDid) + { + var data = new MotionData(); + QualifiedDataId id = animationDid; + data.Anims.Add(new AnimData + { + AnimId = id, + LowFrame = 0, + HighFrame = -1, + Framerate = 10f, + }); + return data; + } + + private static Animation Animation(int frames) + { + var animation = new Animation(); + for (int i = 0; i < frames; i++) + { + var frame = new AnimationFrame(1u); + frame.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(frame); + } + return animation; + } + + private sealed class RecallAnimationLoader : IAnimationLoader + { + private readonly Dictionary _animations = new(); + + internal void Add(uint id, Animation animation) => _animations[id] = animation; + + public Animation? LoadAnimation(uint id) => + _animations.TryGetValue(id, out Animation? animation) ? animation : null; + } + + private sealed class TestRemoteMotion : ILiveEntityRemotePlacementRuntime + { + private uint _cellId; + private Func? _readCell; + private Action? _writeCell; + + public PhysicsBody Body { get; } = new(); + public uint CellId + { + get => _readCell?.Invoke() ?? _cellId; + set + { + _cellId = value; + _writeCell?.Invoke(value); + } + } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + public void HitGround() { } + public void LeaveGround() { } + } + } + + private static DatPhysicsScript PersistentScript() + { + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 0.0, + Hook = new CreateParticleHook + { + EmitterInfoId = EmitterDid, + PartIndex = uint.MaxValue, + EmitterId = 1u, + Offset = new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }, + }, + }); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 60.0, + Hook = new DestroyParticleHook { EmitterId = 1u }, + }); + return script; + } + + private static DatPhysicsScript RecallScript() + { + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 0.0, + Hook = new CreateParticleHook + { + EmitterInfoId = EmitterDid, + PartIndex = uint.MaxValue, + EmitterId = 7u, + Offset = new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }, + }, + }); + return script; + } + + private static EmitterDesc PersistentEmitter() => new() + { + DatId = EmitterDid, + Type = AcDream.Core.Vfx.ParticleType.Still, + EmitterKind = ParticleEmitterKind.BirthratePerSec, + MaxParticles = 2, + InitialParticles = 1, + TotalParticles = 0, + TotalDuration = 0f, + Lifespan = 120f, + LifetimeMin = 120f, + LifetimeMax = 120f, + Birthrate = 0f, + StartSize = 0.25f, + EndSize = 0.25f, + StartAlpha = 1f, + EndAlpha = 1f, + }; + + private static Setup ProjectileSetup() => new() + { + Spheres = + { + new Sphere + { + Origin = new Vector3(0f, -0.165f, 0.1045f), + Radius = 0.102f, + }, + }, + Lights = + { + [0] = new LightInfo + { + ViewSpaceLocation = new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }, + Color = new ColorARGB + { + Red = 255, + Green = 255, + Blue = 255, + Alpha = 255, + }, + Intensity = 1f, + Falloff = 8f, + }, + }, + }; + + private static WorldSession.EntitySpawn MissileSpawn( + uint guid, + ushort generation, + Vector3 position) + { + var wirePosition = new CreateObject.ServerPosition( + CellA, + position.X, + position.Y, + position.Z, + 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: generation); + var physics = new PhysicsSpawnData( + RawState: (uint)MissileState, + Position: wirePosition, + Movement: null, + AnimationFrame: null, + SetupTableId: SetupDid, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: 0f, + Elasticity: 0f, + Translucency: null, + Velocity: new Vector3(2f, 0f, 0f), + Acceleration: null, + AngularVelocity: Vector3.Zero, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + wirePosition, + SetupDid, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "Stress missile", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)MissileState, + Friction: 0f, + Elasticity: 0f, + InstanceSequence: generation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static WorldSession.EntitySpawn RecallSpawn(uint guid, uint cell) + { + var wirePosition = new CreateObject.ServerPosition( + cell, 10f, 10f, 10f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 1, + ServerControlledMove: 1, + ForcePosition: 1, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: wirePosition, + Movement: null, + AnimationFrame: null, + SetupTableId: SetupDid, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + wirePosition, + SetupDid, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "Recall stress owner", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static void AddEmptyPhysicsLandblock( + PhysicsEngine engine, + uint landblockId, + float offsetX, + float offsetY) + { + var heights = new byte[81]; + var table = new float[256]; + Array.Fill(table, -1000f); + engine.AddLandblock( + landblockId, + new TerrainSurface(heights, table), + Array.Empty(), + Array.Empty(), + offsetX, + offsetY); + } + + private static LoadedLandblock EmptyLandblock(uint canonicalId) => + new(canonicalId, new LandBlock(), Array.Empty()); +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index ef48b2f8..341c6115 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -62,6 +62,29 @@ public sealed class LiveEntityRuntimeTests } } + private sealed class CallbackResources : ILiveEntityResourceLifecycle + { + public Action? OnRegister { get; set; } + public Action? OnUnregister { get; set; } + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + public Dictionary UnregisterCountsByGuid { get; } = new(); + + public void Register(WorldEntity entity) + { + RegisterCount++; + OnRegister?.Invoke(entity); + } + + public void Unregister(WorldEntity entity) + { + UnregisterCount++; + UnregisterCountsByGuid[entity.ServerGuid] = + UnregisterCountsByGuid.GetValueOrDefault(entity.ServerGuid) + 1; + OnUnregister?.Invoke(entity); + } + } + [Fact] public void RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate() { @@ -955,6 +978,683 @@ public sealed class LiveEntityRuntimeTests firstLocalEntityId: uint.MaxValue)); } + [Fact] + public void PersistentRescue_DeleteBeforeDrain_ForgetsReferenceAndPersistenceClass() + { + const uint guid = 0x7000002Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + spatial.MarkPersistent(guid); + + spatial.RemoveLandblock(0x0101FFFFu); + + Assert.Equal(1, spatial.PendingRescueCount); + Assert.Equal(1, spatial.PersistentGuidCount); + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.Equal(0, spatial.PendingRescueCount); + Assert.Equal(0, spatial.PersistentGuidCount); + Assert.Empty(spatial.DrainRescued()); + Assert.Equal(0, runtime.Count); + } + + [Fact] + public void SessionClear_ForgetsPersistentClassificationWithoutMaterializedRecord() + { + var spatial = new GpuWorldState(); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + spatial.MarkPersistent(0x7000002Bu); + + runtime.Clear(); + + Assert.Equal(0, spatial.PersistentGuidCount); + Assert.Equal(0, spatial.PendingRescueCount); + } + + [Fact] + public void PersistentClassification_SurvivesSameGuidGenerationReplacementUntilSessionReset() + { + const uint guid = 0x7000002Du; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + spatial.MarkPersistent(guid); + + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + + Assert.Equal(1, spatial.PersistentGuidCount); + spatial.RemoveLandblock(0x0101FFFFu); + Assert.Equal(1, spatial.PendingRescueCount); + + runtime.Clear(); + + Assert.Equal(0, spatial.PersistentGuidCount); + Assert.Equal(0, spatial.PendingRescueCount); + } + + [Fact] + public void RebucketObserverFailure_CommitsCanonicalCellAndProjectionBeforeRethrow() + { + const uint guid = 0x70000040u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + spatial.LiveProjectionVisibilityChanged += (_, _) => + throw new InvalidOperationException("observer failed after spatial commit"); + + Assert.Throws(() => + runtime.RebucketLiveEntity(guid, 0x02020022u)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0x02020022u, record.FullCellId); + Assert.Equal(0x0202FFFFu, record.CanonicalLandblockId); + Assert.True(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Empty(runtime.WorldEntities); + Assert.Equal(1, spatial.PendingLiveEntityCount); + Assert.Equal(0, spatial.PendingVisibilityTransitionCount); + } + + [Fact] + public void WithdrawObserverFailure_CommitsCanonicalWithdrawalBeforeRethrow() + { + const uint guid = 0x70000041u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + runtime.ProjectionVisibilityChanged += (_, visible) => + { + if (!visible) + throw new InvalidOperationException("observer failed after withdrawal"); + }; + + Assert.Throws(() => + runtime.WithdrawLiveEntityProjection(guid)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.Empty(runtime.MaterializedWorldEntities); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(spatial.Entities); + Assert.Equal(0, spatial.PendingLiveEntityCount); + Assert.Equal(0, spatial.PendingVisibilityTransitionCount); + } + + [Fact] + public void WithdrawVisibilityCallback_GuidReplacementPreservesNewIncarnationProjection() + { + const uint guid = 0x70000044u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + bool replaced = false; + runtime.ProjectionVisibilityChanged += (edgeRecord, visible) => + { + if (replaced || visible || edgeRecord.Generation != 1) + return; + replaced = true; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010011u)); + runtime.MaterializeLiveEntity(guid, 0x01010011u, id => Entity(id, guid)); + }; + + Assert.False(runtime.WithdrawLiveEntityProjection(guid)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.Equal(0x01010011u, replacement.FullCellId); + Assert.True(replacement.IsSpatiallyProjected); + Assert.True(replacement.IsSpatiallyVisible); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities)); + } + + [Fact] + public void RebucketSpatialCallback_GuidReplacementPreservesNewIncarnationProjection() + { + const uint guid = 0x70000045u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + bool replaced = false; + spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) => + { + if (replaced || visible || edgeGuid != guid) + return; + replaced = true; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010012u)); + runtime.MaterializeLiveEntity(guid, 0x01010012u, id => Entity(id, guid)); + }; + + Assert.False(runtime.RebucketLiveEntity(guid, 0x02020022u)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.Equal(0x01010012u, replacement.FullCellId); + Assert.Equal(0x0101FFFFu, replacement.CanonicalLandblockId); + Assert.True(replacement.IsSpatiallyProjected); + Assert.True(replacement.IsSpatiallyVisible); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities)); + Assert.Equal(0, spatial.PendingLiveEntityCount); + } + + [Fact] + public void WithdrawVisibilityCallback_RebucketSameRecordSupersedesOuterWithdrawal() + { + const uint guid = 0x70000046u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + bool reprojected = false; + runtime.ProjectionVisibilityChanged += (_, visible) => + { + if (reprojected || visible) + return; + reprojected = true; + Assert.True(runtime.RebucketLiveEntity(guid, 0x01010022u)); + }; + + Assert.False(runtime.WithdrawLiveEntityProjection(guid)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0x01010022u, record.FullCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); + } + + [Fact] + public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove() + { + const uint guid = 0x70000047u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0303FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + bool rebucketed = false; + spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) => + { + if (rebucketed || visible || edgeGuid != guid) + return; + rebucketed = true; + Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u)); + }; + + Assert.False(runtime.RebucketLiveEntity(guid, 0x02020022u)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0x03030033u, record.FullCellId); + Assert.Equal(0x0303FFFFu, record.CanonicalLandblockId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); + Assert.Equal(0, spatial.PendingLiveEntityCount); + } + + [Fact] + public void ReentrantVisibilityGuidReuse_DoesNotApplyQueuedOldEdgeToReplacement() + { + const uint guid = 0x7000002Cu; + var spatial = new GpuWorldState(); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + var edges = new List<(ushort Generation, bool Visible)>(); + bool replaced = false; + runtime.ProjectionVisibilityChanged += (record, visible) => + { + edges.Add((record.Generation, visible)); + if (replaced || record.Generation != 1 || !visible) + return; + replaced = true; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + }; + + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + + Assert.Equal([(1, true), (2, true)], edges); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.True(replacement.IsSpatiallyVisible); + Assert.True(spatial.IsLiveEntityVisible(guid)); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void SessionClearInsideVisibilityCallback_DropsOldQueuedEdgesBeforeReuse() + { + const uint guid = 0x7000002Eu; + var spatial = new GpuWorldState(); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + var edges = new List<(ushort Generation, bool Visible)>(); + bool reset = false; + runtime.ProjectionVisibilityChanged += (record, visible) => + { + edges.Add((record.Generation, visible)); + if (reset || record.Generation != 1 || !visible) + return; + reset = true; + runtime.Clear(); + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + }; + + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + + Assert.Equal([(1, true), (2, true)], edges); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.True(replacement.IsSpatiallyVisible); + Assert.Equal(0, spatial.PendingVisibilityTransitionCount); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void DeleteCallback_CanRegisterNextGenerationButCannotMaterializeBeforeOldTeardown() + { + const uint guid = 0x70000031u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new RecordingResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity old = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + AggregateException error = Assert.Throws(() => + runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false, + beforeTeardown: () => + { + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + })); + + Assert.Contains( + error.Flatten().InnerExceptions, + exception => exception is InvalidOperationException + && exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal)); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)2, current.Generation); + Assert.Null(current.WorldEntity); + Assert.Empty(spatial.Entities); + Assert.DoesNotContain(guid, runtime.WorldEntities.Keys); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.False(runtime.TryGetServerGuid(old.Id, out _)); + } + + [Fact] + public void GenerationTeardownCallback_NewerReentrantGenerationSupersedesOuterCreate() + { + const uint guid = 0x70000032u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u)); + }; + + LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); + Assert.False(outer.LogicalRegistrationCreated); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)3, current.Generation); + Assert.Null(current.WorldEntity); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void GenerationTeardownCallback_UnrelatedRegistrationDoesNotSupersedeOuterCreate() + { + const uint guid = 0x7000003Bu; + const uint unrelatedGuid = 0x7000003Cu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + runtime.RegisterLiveEntity(Spawn(unrelatedGuid, 1, 1, 0x01010001u)); + }; + + LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, outer.Inbound.Disposition); + Assert.True(outer.LogicalRegistrationCreated); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.True(runtime.TryGetRecord(unrelatedGuid, out _)); + Assert.Equal(2, runtime.Count); + } + + [Fact] + public void GenerationTeardownCallback_AcceptedDeletePreventsOuterGenerationResurrection() + { + const uint guid = 0x70000035u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 2), + isLocalPlayer: false)); + }; + + LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); + Assert.False(outer.LogicalRegistrationCreated); + Assert.False(runtime.TryGetRecord(guid, out _)); + Assert.False(runtime.TryGetSnapshot(guid, out _)); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void GenerationTeardownCallback_DeleteThenRegisterCannotAbaOuterEpoch() + { + const uint guid = 0x70000042u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 2), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u)); + }; + + LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); + Assert.False(outer.LogicalRegistrationCreated); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)3, current.Generation); + Assert.Null(current.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void GenerationTeardownCallback_NewerGenerationAndFailureIsNeverSilentlyDiscarded() + { + const uint guid = 0x70000043u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u)); + throw new InvalidOperationException("old incarnation cleanup failed"); + }; + + AggregateException error = Assert.Throws(() => + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u))); + + Assert.Contains( + error.Flatten().InnerExceptions, + exception => exception is InvalidOperationException + && exception.Message == "old incarnation cleanup failed"); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)3, current.Generation); + Assert.Null(current.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void GenerationTeardownCallback_SessionClearPreventsOuterGenerationResurrection() + { + const uint guid = 0x70000036u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + runtime.Clear(); + }; + + LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010001u)); + + Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); + Assert.False(outer.LogicalRegistrationCreated); + Assert.False(runtime.TryGetRecord(guid, out _)); + Assert.False(runtime.TryGetSnapshot(guid, out _)); + Assert.Empty(spatial.Entities); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + + [Fact] + public void ResourceRegistrationCallback_CannotReplaceIncarnationAndRollsBackOwner() + { + const uint guid = 0x70000037u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + resources.OnRegister = _ => + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + + InvalidOperationException error = Assert.Throws(() => + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid))); + + Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)1, current.Generation); + Assert.Null(current.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(spatial.Entities); + } + + [Fact] + public void ResourceRegistrationCallback_CannotClearSessionAndRollsBackOwner() + { + const uint guid = 0x70000038u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + resources.OnRegister = _ => runtime.Clear(); + + InvalidOperationException error = Assert.Throws(() => + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid))); + + Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Null(current.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(spatial.Entities); + } + + [Fact] + public void SessionClear_ReentrantDeleteOfLaterSnapshot_TearsEachRecordDownOnce() + { + const uint firstGuid = 0x70000039u; + const uint secondGuid = 0x7000003Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(firstGuid, 1, 1, 0x01010001u)); + runtime.RegisterLiveEntity(Spawn(secondGuid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(firstGuid, 0x01010001u, id => Entity(id, firstGuid)); + runtime.MaterializeLiveEntity(secondGuid, 0x01010001u, id => Entity(id, secondGuid)); + resources.OnUnregister = entity => + { + if (entity.ServerGuid != firstGuid) + return; + resources.OnUnregister = null; + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(secondGuid, InstanceSequence: 1), + isLocalPlayer: false)); + }; + + runtime.Clear(); + + Assert.Equal(1, resources.UnregisterCountsByGuid[firstGuid]); + Assert.Equal(1, resources.UnregisterCountsByGuid[secondGuid]); + Assert.Equal(2, resources.UnregisterCount); + Assert.Equal(0, runtime.Count); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(spatial.Entities); + } + + [Fact] + public void SessionClear_RejectsRegistrationFromTeardownCallbackWithoutLeakingOwner() + { + const uint oldGuid = 0x70000033u; + const uint callbackGuid = 0x70000034u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new CallbackResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(oldGuid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity(oldGuid, 0x01010001u, id => Entity(id, oldGuid)); + resources.OnUnregister = _ => + { + resources.OnUnregister = null; + runtime.RegisterLiveEntity(Spawn(callbackGuid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + callbackGuid, + 0x01010001u, + id => Entity(id, callbackGuid)); + }; + + AggregateException error = Assert.Throws(() => runtime.Clear()); + + Assert.Contains( + error.Flatten().InnerExceptions, + exception => exception is InvalidOperationException + && exception.Message.Contains("while the session lifetime is clearing", StringComparison.Ordinal)); + Assert.Equal(0, runtime.Count); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.MaterializedWorldEntities); + Assert.Empty(spatial.Entities); + Assert.Equal(0, spatial.PendingLiveEntityCount); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + } + [Fact] public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails() {