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() {