test(vfx): harden live missile and effect lifetimes
Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset. Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures. Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
1e98d81448
commit
8d63e5c28a
21 changed files with 2704 additions and 100 deletions
|
|
@ -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<MeshRef> 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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Transfers any older deferred shadow restoration before the accepted
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
private readonly Action<uint> _ownerUnregistered;
|
||||
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
||||
private readonly Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
|
||||
private readonly HashSet<uint> _readyServerGuids = new();
|
||||
private readonly Dictionary<uint, ushort> _readyGenerationByServerGuid = new();
|
||||
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||
private readonly HashSet<uint> _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();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every live root after animation/movement.</summary>
|
||||
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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ public sealed class LiveEntityLightController : IDisposable
|
|||
|
||||
public void Refresh() => _lighting.RefreshAttachedLights();
|
||||
|
||||
internal int TrackedOwnerCount => _serverGuidByOwner.Count;
|
||||
internal int PresentedOwnerCount => _presentOwners.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Attached projection visibility can become true before its holding-part
|
||||
/// composition is published. The attachment owner calls this barrier only
|
||||
|
|
|
|||
|
|
@ -199,6 +199,10 @@ public sealed class GpuWorldState
|
|||
/// pending path is doing its job.
|
||||
/// </summary>
|
||||
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<WorldEntity> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void RemoveLiveEntityProjection(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0) return;
|
||||
|
||||
bool rebuiltLoaded = false;
|
||||
foreach (var kvp in _loaded.ToArray())
|
||||
{
|
||||
IReadOnlyList<WorldEntity> 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<WorldEntity> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends all spatial lifetime retained for one server object. Temporary
|
||||
/// rebucketing uses <see cref="RemoveLiveEntityProjection"/> and keeps the
|
||||
/// persistence classification; logical delete also forgets that class so
|
||||
/// a later session/GUID reuse cannot be rescued as the old player.
|
||||
/// </summary>
|
||||
public void ForgetLiveEntity(uint serverGuid)
|
||||
{
|
||||
RemoveLiveEntityProjection(serverGuid);
|
||||
_persistentGuids.Remove(serverGuid);
|
||||
_persistentInFlatProbe.Remove(serverGuid);
|
||||
}
|
||||
|
||||
/// <summary>Clears session-scoped persistence and undrained rescues.</summary>
|
||||
public void ClearLiveEntityLifetimeState()
|
||||
{
|
||||
_persistentGuids.Clear();
|
||||
_persistentRescued.Clear();
|
||||
_persistentInFlatProbe.Clear();
|
||||
_visibilityTransitions.Clear();
|
||||
_visibleLiveGuids.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<Exception>? failures = null;
|
||||
_dispatchingVisibilityTransitions = true;
|
||||
try
|
||||
{
|
||||
while (_visibilityTransitions.TryDequeue(out var transition))
|
||||
{
|
||||
LiveProjectionVisibilityChanged?.Invoke(
|
||||
transition.ServerGuid,
|
||||
transition.Visible);
|
||||
Delegate[] subscribers = LiveProjectionVisibilityChanged?
|
||||
.GetInvocationList()
|
||||
?? Array.Empty<Delegate>();
|
||||
for (int i = 0; i < subscribers.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
((Action<uint, bool>)subscribers[i])(
|
||||
transition.ServerGuid,
|
||||
transition.Visible);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<uint, uint> _guidByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
|
||||
private bool _isClearing;
|
||||
private bool _isRegisteringResources;
|
||||
private int _logicalTeardownDepth;
|
||||
private ulong _sessionLifetimeVersion;
|
||||
private readonly Dictionary<uint, ulong> _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<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
beforeTeardown?.Invoke();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).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<Exception>? failures = null;
|
||||
_logicalTeardownDepth++;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(record);
|
||||
beforeTeardown?.Invoke();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
|
||||
if (record is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(record);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).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<Exception>()).Add(error);
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
|
|
@ -905,26 +1071,54 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
public void Clear()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
|
||||
if (_isClearing || _isRegisteringResources)
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(record);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).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<Exception>? 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<Exception>()).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<Delegate>();
|
||||
List<Exception>? failures = null;
|
||||
for (int i = 0; i < subscribers.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
((Action<LiveEntityRecord, bool>)subscribers[i])(record, visible);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).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;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ public sealed class LightingHookSink : IAnimationHookSink
|
|||
/// </summary>
|
||||
public event Action<uint, bool>? OwnerLightingChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -540,6 +540,18 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
public int TotalRegistered => _entityToCells.Count;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="TotalRegistered"/> so a retained collision payload cannot
|
||||
/// survive its owning live-object incarnation unnoticed.
|
||||
/// </summary>
|
||||
public int RetainedRegistrationCount => _entityReg.Count;
|
||||
|
||||
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
|
||||
public int SuspendedRegistrationCount => _suspendedEntities.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
|
||||
/// Single-shape entities return one entry per shape; multi-part entities
|
||||
|
|
|
|||
|
|
@ -42,6 +42,18 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
|
||||
public Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<ulong, int> 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AggregateException>(() =>
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblock,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>())));
|
||||
|
||||
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<AggregateException>(() =>
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblock,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>())));
|
||||
|
||||
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<MeshRef>(),
|
||||
};
|
||||
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
1049
tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
Normal file
1049
tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -62,6 +62,29 @@ public sealed class LiveEntityRuntimeTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class CallbackResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public Action<WorldEntity>? OnRegister { get; set; }
|
||||
public Action<WorldEntity>? OnUnregister { get; set; }
|
||||
public int RegisterCount { get; private set; }
|
||||
public int UnregisterCount { get; private set; }
|
||||
public Dictionary<uint, int> 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<AggregateException>(() =>
|
||||
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<AggregateException>(() =>
|
||||
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<AggregateException>(() =>
|
||||
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<AggregateException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<AggregateException>(() => 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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue