feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -277,6 +277,24 @@ Chorizite.DatReaderWriter, and replace only its incomplete
|
|||
payload. Core consumes the loaders through delegates/interfaces and therefore
|
||||
does not depend on Content or a database implementation.
|
||||
|
||||
Runtime effect delivery is owned by App-layer `EntityEffectController`. It
|
||||
retains pre-materialization F754/F755 packets in one mixed FIFO per server GUID,
|
||||
resolves the canonical local ID through `LiveEntityRuntime` only after
|
||||
renderer/resources/profile are ready, and resolves typed/default/part-default
|
||||
hooks through the live owner's current `EntityEffectProfile`. It does not own a
|
||||
second GUID map. The same profile publishes Setup/static or network/live
|
||||
SoundTable ownership at readiness and on every same-generation PhysicsDesc
|
||||
replacement, including present-zero clearing. Core `PhysicsScriptRunner` mirrors retail's one
|
||||
serial FIFO per owner: duplicate plays append, owners progress independently,
|
||||
`CallPES` uses an injected uniform delay, and every hook traverses the shared
|
||||
`AnimationHookRouter`. Existing queues on cell-less or Frozen live roots do
|
||||
not advance; a new play for an already-created cell-less object is dropped as in
|
||||
retail. Attached children advance through their eligible parent. Every effect
|
||||
resource uses the canonical, globally unique `WorldEntity.Id`; static
|
||||
allocators fail before their namespace can wrap. Live anchors are refreshed from the current
|
||||
`WorldEntity` before script dispatch; Step 5 extends that root pose to exact
|
||||
animated part transforms for emitters and dynamic lights.
|
||||
|
||||
The remaining aggregation is primarily `_playerController`'s player-specific
|
||||
movement plus the separate `WorldEntity`/animation/physics component types.
|
||||
Those should become ONE class:
|
||||
|
|
|
|||
|
|
@ -232,8 +232,14 @@ instead of spread across `GameWindow`.
|
|||
latest accepted immutable CreateObject snapshot, owns the canonical local ID
|
||||
and optional runtime components, and separates logical registration from
|
||||
spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved
|
||||
relations by child and parent generation. A future general mixed packet queue
|
||||
still has to cover the non-Parent packet families tracked by divergence AD-32.
|
||||
relations by child and parent generation. `Rendering/Vfx/EntityEffectController`
|
||||
owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table
|
||||
resolution, and a readiness set; canonical ServerGuid-to-local-ID translation
|
||||
always stays in `LiveEntityRuntime`. `EntityScriptActivator` uses the same
|
||||
canonical `WorldEntity.Id` as rendering and physics; the disjoint static ID
|
||||
allocators fail fast instead of wrapping into another landblock's namespace.
|
||||
All other non-Parent packet
|
||||
families still need the future general queue tracked by divergence AD-32.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -295,7 +301,8 @@ retained owners. Top-level spawn publication is one-shot per incarnation, so
|
|||
leave/re-entry restores presentation without duplicating plugin event replay.
|
||||
|
||||
**Remaining target:** the player-specific controller is still a separate
|
||||
aggregation, and AD-32 still tracks the future non-Parent mixed-packet queue.
|
||||
aggregation. The focused effect queue is shipped; AD-32 now tracks only the
|
||||
future non-effect, non-Parent packet queue.
|
||||
|
||||
**Behavior change:** Spatial withdrawal and re-entry now preserve logical
|
||||
identity and active resources instead of replaying create-time effects.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 1. Intentional architecture (IA) — 18 rows
|
||||
## 1. Intentional architecture (IA) — 17 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -46,7 +46,6 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
|
||||
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
|
||||
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
|
||||
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
|
||||
| IA-8 | Synthetic outdoor cell node as render root (outdoor-as-cell, Option A): one unified `DrawInside` path; retail roots at a real CLandCell with a separate outdoor pipeline | `src/AcDream.App/Rendering/OutdoorCellNode.cs:23` | Eliminating the inside/outside render branch kills the indoor FLAP by construction (2026-06-07 cutover); R-A2 restored retail's per-building flood topology | Any consumer assuming the root is a real cell mis-handles the synthetic node — historically the 2↔6 flood-depth oscillation and doorway-flap class | `SmartBox::RenderNormalMode` → DrawInside, decomp:92635; `LScape::draw` 0x00506330; ConstructView(CBldPortal) decomp:433827 |
|
||||
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
||||
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
||||
|
|
@ -62,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 2. Adaptation (AD) — 37 rows (AD-42 added 2026-07-12 — enter-world placement is split across the existing snap plus the ported object-aware ring search)
|
||||
## 2. Adaptation (AD) — 38 rows (AD-43 added 2026-07-14 — malformed zero-time PhysicsScript recursion fails diagnostically instead of hanging the update thread)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -76,7 +75,6 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
||||
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
||||
| AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB |
|
||||
| AD-14 | Script anchor world position cached at `Play()` time; retail fires hooks via vtable dispatch on the live owning PhysicsObj | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:55` | Core's runner is decoupled from the entity graph; documented contract pushes per-frame anchor refresh to the owning subsystem (done for AttachLocal) | Any caller that forgets the per-frame refresh strands long-running effects at the spawn position while the entity walks away | FUN_0051bfb0 per-frame hook dispatch on owner |
|
||||
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
|
||||
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
|
||||
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
|
||||
|
|
@ -93,7 +91,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
||||
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
|
||||
| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. Effect packets share the missing queue: pre-materialization F754 currently plays immediately under a temporary server-GUID/camera anchor (teardown cleans that alias), while parsed F755 has no App consumer yet. Older-incarnation state packets drop in both clients. ParentEvent is no longer part of this divergence: runtime-owned `ParentAttachmentState` retains it by parent generation and replays it after both objects exist. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/World/LiveEntityRuntime.cs`; `GameWindow.OnPlayScriptReceived`; `WorldSession.PlayPhysicsScriptTypeReceived`; Parent exception `src/AcDream.App/World/ParentAttachmentState.cs` | acdream has no general per-object mixed packet queue yet. Dropping state is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. The temporary F754 owner is lifecycle-safe but not presentation-faithful. The shipped runtime now owns the focused Parent queue and is the seam where the complete mixed pending queue belongs. | The first queued non-Parent state can be absent until a later update; an early direct effect can appear at the camera instead of its owner; a typed effect can be absent entirely | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; F754/F755 dispatch and pending replay pc:357214-357239 |
|
||||
| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. F754/F755 and ParentEvent are no longer part of this divergence: `EntityEffectController` preserves one mixed effect FIFO per server GUID until the canonical local owner is ready, while runtime-owned `ParentAttachmentState` retains parent relations by generation. Older-incarnation state packets drop in both clients. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/World/LiveEntityRuntime.cs`; effect exception `src/AcDream.App/Rendering/Vfx/EntityEffectController.cs`; Parent exception `src/AcDream.App/World/ParentAttachmentState.cs` | acdream has no general per-object mixed queue for non-effect state packets yet. Dropping those state families is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. The canonical runtime remains the seam for widening the queue later. | The first queued non-effect state can be absent until a later authoritative update; effect and Parent packets retain retail order and ownership | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; F754/F755 dispatch and pending replay pc:357214-357239 |
|
||||
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
|
||||
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
|
||||
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
|
||||
|
|
@ -104,10 +102,11 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.App/Input/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) |
|
||||
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
|
||||
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
|
||||
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 97 active rows
|
||||
## 3. Documented approximation (AP) — 88 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
|
||||
|
|
@ -217,7 +216,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) — 40 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) — 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)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -232,7 +231,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-10 | Setup lights anchored at entity root — per-light Frames not transformed through the animated part chain | `src/AcDream.Core/Lighting/LightInfoLoader.cs:31` | Per-part world transforms aren't exposed to the lighting layer; awaiting animation hook integration | A carried torch glows from the character origin, not the hand, and doesn't track swing/idle animations | LightInfo.ViewSpaceLocation per-part Frame (r13 §1) |
|
||||
| TS-11 | Retail's complete inherited `CreateBlockingParticleHook` payload is decoded by the narrow `DatCollection`-backed compatibility reader, but `ParticleHookSink` deliberately no-ops it until the live emitter-binding owner can apply same-live-nonzero-logical-ID suppression | `src/AcDream.Core/Vfx/ParticleHookSink.cs` (`RetailCreateBlockingParticleHook` case); decoder `src/AcDream.Content/Vfx/RetailAnimationHookReader.cs` | Decoding and cursor alignment are now exact for both PhysicsScripts and Animations; executing it as an ordinary replacement before Step 5 would encode the wrong lifetime rule | Blocking-particle hooks still emit nothing until Step 5; ordinary hooks following them are no longer cursor-misaligned | `CreateBlockingParticleHook::Execute` 0x00526EF0; `ParticleManager::CreateBlockingParticleEmitter` 0x0051B8A0; r04 §6 |
|
||||
| TS-12 | Animated entities' emitters use rest-pose part transforms anchored at entity root; retail attaches to the live animated part (per-tick refresh deferred; statics fixed by C.1.5b/#56) | `src/AcDream.Core/Vfx/ParticleHookSink.cs:80` (+ :20) | The renderer doesn't expose per-part world transforms to VFX; root + precomputed matrices reproduce retail placement for everything that doesn't animate | Effects hooked to animated parts (swinging hand, nodding head) emit from the rest pose / float at spawn offsets instead of tracking motion | `ParticleEmitter::UpdateParticles` 0x0051d2d4 |
|
||||
| TS-13 | `DefaultScriptHook` / `DefaultScriptPartHook` / `CallPESHook` animation hooks dropped (no OnHook case); blocker comment predates PhysicsScriptRunner (C.1.5a) and may be STALE | `src/AcDream.Core/Vfx/ParticleHookSink.cs:130` | Originally blocked on PhysicsScript dat exposure; spawn-time DefaultScript firing landed via EntityScriptActivator, the animation-frame path never did | VFX retail triggers from specific animation frames (mid-animation script calls) never appear | CallPES / DefaultScript hook dispatch (r04 §6) |
|
||||
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
|
||||
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
|
||||
| TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.7–1.0 m) + screen rect matched to the indicator; retail's per-polygon refine deferred (**#71**); rect-over-circle is a user-approved UX divergence | `src/AcDream.Core/Selection/WorldPicker.cs:199` | Stage B only needed if visual testing surfaces Stage-A over-picks; sphere/rect + cell-BSP occlusion adequate so far | Clicks near (not on) an entity still select it; fixed radii can mis-prioritize overlapping candidates vs retail's polygon-accurate test | `CPolygon::polygon_hits_ray` 0x0054c889 |
|
||||
|
|
@ -297,9 +295,8 @@ WITH that phase, not before.
|
|||
7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
|
||||
8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
|
||||
9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
|
||||
10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
|
||||
11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
|
||||
12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
|
||||
10. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
|
||||
11. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
|
||||
|
||||
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
|
||||
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
|
||||
|
|
@ -307,8 +304,8 @@ TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
|
|||
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
|
||||
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
|
||||
Membership Stage 2 must land TS-18 (BuildingCellId).
|
||||
The audio phase lands TS-9/TS-29; the animation-hook layer lands
|
||||
TS-10/TS-11/TS-12/TS-13/TS-14.
|
||||
The audio phase lands TS-9/TS-29; the live-pose animation-hook layer lands
|
||||
TS-10/TS-11/TS-12/TS-14.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`.
|
||||
- **Wave 4.6 item giving implemented 2026-07-13; starter-dungeon live gate pending.** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`.
|
||||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||
- **Missile/portal VFX campaign Steps 0–3 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading now shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. AP-69 remains narrowed to the missing retail liveness cull, AD-32 to the remaining mixed pre-create queue, and TS-11 to Step 5's not-yet-landed live logical-emitter suppression.
|
||||
- **Missile/portal VFX campaign Steps 0–4 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, and TS-13 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, AD-43 registers the corrupt-DAT zero-time-cycle safety boundary, and TS-11 remains Step 5's live logical-emitter suppression.
|
||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
||||
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ 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–3
|
||||
- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–4
|
||||
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
|
||||
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
|
||||
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
|
||||
|
|
@ -480,9 +480,23 @@ include dungeons.
|
|||
cannot replay create-time scripts. A narrow raw-hook seam now decodes retail's
|
||||
inherited `CreateBlockingParticle` payload for both PhysicsScripts and
|
||||
Animations, while exact stored-order `PhysicsScriptTable` threshold lookup and
|
||||
Setup-versus-network default precedence are pinned by conformance tests. The
|
||||
remaining steps port per-owner effect scheduling, live animated attachment
|
||||
poses, projectile motion and sweeps, and Hidden/UnHide portal presentation.
|
||||
Setup-versus-network default precedence are pinned by conformance tests.
|
||||
`EntityEffectController` now replays pre-create F754/F755 once in mixed order
|
||||
against the canonical local owner; the Core scheduler uses one serial FIFO per
|
||||
owner, preserves duplicates, supports deterministic delayed `CallPES`, and
|
||||
routes default/part-default hooks through the complete hook fan-out. The
|
||||
update-frame clock is published before packet/default dispatch and republished
|
||||
before animation hooks. Existing cell-less/Frozen queues pause and resume with
|
||||
retail eligibility, while new plays on an already-created cell-less owner drop;
|
||||
attached children advance through their parent. Every sink shares canonical
|
||||
`WorldEntity.Id`, and static allocator overflow fails before aliasing another
|
||||
landblock. Live SoundTable ownership is installed independently of animation
|
||||
eligibility and replaced/cleared on each PhysicsDesc application; DAT statics
|
||||
retain Setup defaults. Concurrent-safe DAT reads stay off the streaming lock; malformed
|
||||
zero-time recursive chains fail diagnostically while valid timed weather loops
|
||||
continue. The
|
||||
remaining steps port live animated attachment poses, projectile motion and
|
||||
sweeps, and Hidden/UnHide portal presentation.
|
||||
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using AcDream.Core.World;
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -28,6 +29,9 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
|
||||
|
||||
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
|
||||
|
||||
/// <summary>Raised after the attached projection is fully registered.</summary>
|
||||
public event Action<uint>? EntityReady;
|
||||
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
|
||||
|
||||
public IEnumerable<uint> AttachedEntityIds
|
||||
|
|
@ -283,6 +287,43 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
$"location={parentLocation} placement={placement}");
|
||||
Relations.MarkProjected(childGuid);
|
||||
EntityReady?.Invoke(childGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves retail's parent Setup part index to the currently attached
|
||||
/// child local ID for DefaultScriptPartHook.
|
||||
/// </summary>
|
||||
public uint? FindChildLocalIdAtPart(uint parentLocalId, uint partIndex)
|
||||
{
|
||||
foreach (AttachedChild child in _attachedByChild.Values)
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
|
||||
|| parent.Id != parentLocalId
|
||||
|| !child.ParentSetup.HoldingLocations.TryGetValue(
|
||||
child.ParentLocation,
|
||||
out LocationType? holding)
|
||||
|| holding.PartId != partIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return child.Entity.Id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns the live parent whose UpdateChild path owns this child.</summary>
|
||||
public uint? FindParentLocalId(uint childLocalId)
|
||||
{
|
||||
foreach (AttachedChild child in _attachedByChild.Values)
|
||||
{
|
||||
if (child.Entity.Id != childLocalId)
|
||||
continue;
|
||||
if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
|
||||
return parent.Id;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ResolveRelations(uint childGuid)
|
||||
|
|
|
|||
|
|
@ -333,10 +333,13 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
||||
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
||||
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
|
||||
// from the server and schedules the dat-defined hooks (particle spawns,
|
||||
// sounds, light toggles) at their StartTime offsets.
|
||||
// and typed PlayScriptType (0xF755) events through one effect owner, then
|
||||
// fans every dat-defined hook to particles, audio, lights, translucency,
|
||||
// and nested/default-script routing at its StartTime offset.
|
||||
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
|
||||
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
|
||||
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
|
||||
private double _physicsScriptGameTime;
|
||||
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
|
||||
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
|
||||
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
|
||||
|
|
@ -1451,7 +1454,8 @@ public sealed class GameWindow : IDisposable
|
|||
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
|
||||
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
|
||||
_physicsScriptLoader.LoadPhysicsScript,
|
||||
_particleSink);
|
||||
_hookRouter,
|
||||
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
|
||||
|
||||
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
|
||||
// owner-tagged lights so ignite-torch animations light up,
|
||||
|
|
@ -2295,16 +2299,22 @@ public sealed class GameWindow : IDisposable
|
|||
// _particleSink are initialised earlier in OnLoad (line ~1083); both
|
||||
// are non-null here. The resolver lambda captures _dats and swallows
|
||||
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
|
||||
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
|
||||
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
DatReaderWriter.DBObjs.Setup? setup =
|
||||
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
|
||||
e.SourceGfxObjOrSetupId);
|
||||
if (setup is null) return null;
|
||||
uint scriptId = setup.DefaultScript.DataId;
|
||||
if (scriptId == 0) return null;
|
||||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
|
||||
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
|
||||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
|
||||
scriptId,
|
||||
parts,
|
||||
profile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -2312,7 +2322,17 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||||
_scriptRunner!, _particleSink!, ResolveActivation);
|
||||
_scriptRunner!,
|
||||
_particleSink!,
|
||||
ResolveActivation,
|
||||
(ownerId, entity, profile) =>
|
||||
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
|
||||
ownerId =>
|
||||
{
|
||||
entityEffects?.OnDatStaticEntityRemoved(ownerId);
|
||||
_lightingSink?.UnregisterOwner(ownerId);
|
||||
_translucencyFades.ClearEntity(ownerId);
|
||||
});
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
|
||||
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
|
||||
|
|
@ -2336,7 +2356,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
entityScriptActivator.OnRemove(entity.Id);
|
||||
entityScriptActivator.OnRemove(entity);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -2352,6 +2372,27 @@ public sealed class GameWindow : IDisposable
|
|||
_liveEntities,
|
||||
TryAcceptParentForRender);
|
||||
|
||||
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
|
||||
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
|
||||
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
|
||||
_liveEntities,
|
||||
_scriptRunner!,
|
||||
tableResolver,
|
||||
(parentLocalId, partIndex) =>
|
||||
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
|
||||
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
|
||||
ownerId => _entitySoundTables?.Remove(ownerId),
|
||||
(ownerId, soundTableDid) =>
|
||||
{
|
||||
_entitySoundTables?.Remove(ownerId);
|
||||
if (soundTableDid is { } did)
|
||||
_entitySoundTables?.Set(ownerId, did);
|
||||
});
|
||||
_entityEffects = entityEffects;
|
||||
_equippedChildRenderer.EntityReady += guid =>
|
||||
entityEffects.OnLiveEntityReady(guid);
|
||||
_hookRouter.Register(entityEffects);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||
_classificationCache, _translucencyFades);
|
||||
|
|
@ -2575,9 +2616,9 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
finally
|
||||
{
|
||||
// F754 can precede CreateObject, so some temporary owners have no
|
||||
// LiveEntityRecord for the normal logical teardown to visit.
|
||||
_entityScriptActivator?.ClearLegacyPendingOwners();
|
||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2617,6 +2658,8 @@ public sealed class GameWindow : IDisposable
|
|||
// gestures, AND — per Agent #5 research — lightning
|
||||
// flashes during stormy weather.
|
||||
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
|
||||
_liveSession.PlayPhysicsScriptTypeReceived += message =>
|
||||
_entityEffects?.HandleTyped(message);
|
||||
|
||||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
||||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
||||
|
|
@ -3886,16 +3929,6 @@ public sealed class GameWindow : IDisposable
|
|||
Sequencer = sequencer,
|
||||
};
|
||||
|
||||
// Phase E.2: register entity's SoundTable so SoundTableHook can
|
||||
// resolve creature-specific sounds (footsteps, attack vocalizations,
|
||||
// damage grunts, etc). Server-sent SoundTable override would take
|
||||
// precedence here when the wire layer delivers it.
|
||||
if (_entitySoundTables is not null)
|
||||
{
|
||||
uint soundTableId = (uint)setup.DefaultSoundTable;
|
||||
if (soundTableId != 0)
|
||||
_entitySoundTables.Set(entity.Id, soundTableId);
|
||||
}
|
||||
}
|
||||
else if (!retainedAnimationRuntime && _animLoader is not null)
|
||||
{
|
||||
|
|
@ -3964,6 +3997,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Renderer, script owner, optional animation owner, collision body,
|
||||
// and effect profile are now all installed. Only at this boundary may
|
||||
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
|
||||
_entityEffects?.OnLiveEntityReady(spawn.Guid);
|
||||
|
||||
// Dump a summary periodically so we can see drop breakdowns without
|
||||
// waiting for a graceful shutdown.
|
||||
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
|
||||
|
|
@ -4007,12 +4045,11 @@ public sealed class GameWindow : IDisposable
|
|||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
// A Delete can arrive before CreateObject, in which case no instance
|
||||
// timestamp owner exists and the tracked F754 alias must be cleaned
|
||||
// directly. For a known record, cleanup belongs after the runtime's
|
||||
// generation gate: a stale Delete must not cancel the current
|
||||
// incarnation's effect.
|
||||
// timestamp owner exists and its mixed pending effect FIFO must be
|
||||
// discarded directly. For a known record, cleanup belongs after the
|
||||
// generation gate: a stale Delete must not cancel the current owner.
|
||||
if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
|
||||
_entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u);
|
||||
_entityEffects?.ForgetUnknownOwner(delete.Guid);
|
||||
bool removed = _liveEntities!.UnregisterLiveEntity(
|
||||
delete,
|
||||
isLocalPlayer: delete.Guid == _playerServerGuid,
|
||||
|
|
@ -4379,6 +4416,7 @@ public sealed class GameWindow : IDisposable
|
|||
&& effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
|
||||
{
|
||||
liveProfile.ApplyNetworkDescription(refresh.Description);
|
||||
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
|
||||
}
|
||||
|
||||
OnLiveAppearanceUpdated(refresh.Appearance);
|
||||
|
|
@ -4785,15 +4823,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
|
||||
{
|
||||
// AD-32 transitional F754 ownership: before Step 4's mixed pending
|
||||
// packet FIFO, a direct script received before materialization is
|
||||
// temporarily keyed by server GUID. It is still part of this logical
|
||||
// incarnation and must be stopped even when no WorldEntity was ever
|
||||
// materialized. Normal Setup/F754 owners use the local ID and are
|
||||
// stopped by the resource lifecycle after this callback.
|
||||
_entityScriptActivator?.OnRemoveLegacyOwner(
|
||||
record.ServerGuid,
|
||||
record.LocalEntityId ?? 0u);
|
||||
_entityEffects?.OnLiveEntityUnregistered(record);
|
||||
|
||||
if (record.WorldEntity is not { } existingEntity)
|
||||
return;
|
||||
|
|
@ -6565,13 +6595,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
|
||||
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
|
||||
/// Known server GUIDs are translated to the canonical local entity ID so
|
||||
/// logical teardown stops both Setup and network-triggered scripts through
|
||||
/// one owner key. Their current entity position is the anchor. Unknown
|
||||
/// owners retain the legacy camera anchor until Step 4's pending FIFO can
|
||||
/// replay them after materialization.
|
||||
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
|
||||
/// GUID translation and pre-materialization queueing.
|
||||
///
|
||||
/// <para>
|
||||
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
|
||||
|
|
@ -6580,27 +6605,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
|
||||
{
|
||||
if (_scriptRunner is null) return;
|
||||
|
||||
var camWorldPos = System.Numerics.Vector3.Zero;
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
|
||||
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 anchor = camWorldPos;
|
||||
if (_liveEntities?.TryGetWorldEntity(message.Guid, out var entity) == true)
|
||||
{
|
||||
anchor = entity.Position;
|
||||
_scriptRunner.Play(message.ScriptDid, entity.Id, anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
_entityScriptActivator?.PlayLegacyPending(
|
||||
message.Guid,
|
||||
message.ScriptDid,
|
||||
anchor);
|
||||
_entityEffects?.HandleDirect(message);
|
||||
}
|
||||
|
||||
private void UpdateSkyPes(
|
||||
|
|
@ -6628,6 +6633,7 @@ public sealed class GameWindow : IDisposable
|
|||
continue;
|
||||
|
||||
uint skyEntityId = SkyPesEntityId(key);
|
||||
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
|
||||
var renderPass = obj.IsPostScene
|
||||
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
|
||||
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
|
||||
|
|
@ -6652,6 +6658,7 @@ public sealed class GameWindow : IDisposable
|
|||
else
|
||||
{
|
||||
_missingSkyPes.Add(key);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.ClearEntityRenderPass(skyEntityId);
|
||||
}
|
||||
}
|
||||
|
|
@ -6663,7 +6670,8 @@ public sealed class GameWindow : IDisposable
|
|||
continue;
|
||||
|
||||
uint skyEntityId = SkyPesEntityId(key);
|
||||
_scriptRunner.Stop(key.PesObjectId, skyEntityId);
|
||||
_scriptRunner.StopAllForEntity(skyEntityId);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
|
||||
_activeSkyPes.Remove(key);
|
||||
}
|
||||
|
|
@ -6687,8 +6695,8 @@ public sealed class GameWindow : IDisposable
|
|||
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
|
||||
}
|
||||
|
||||
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
|
||||
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
|
||||
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
|
||||
entity.Id;
|
||||
|
||||
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
|
||||
private string? _lastOutStagePtSig;
|
||||
|
|
@ -7221,6 +7229,10 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (localIndex > 0xFFu)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = sceneryIdBase + localIndex++,
|
||||
|
|
@ -7292,7 +7304,6 @@ public sealed class GameWindow : IDisposable
|
|||
// why this is safe (nothing decodes X/Y back out of an entity id).
|
||||
uint interiorLbX = (landblockId >> 24) & 0xFFu;
|
||||
uint interiorLbY = (landblockId >> 16) & 0xFFu;
|
||||
uint interiorIdBase = AcDream.Core.World.InteriorEntityIdAllocator.Base(interiorLbX, interiorLbY);
|
||||
uint localCounter = 0;
|
||||
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
|
|
@ -7481,20 +7492,12 @@ public sealed class GameWindow : IDisposable
|
|||
var worldPos = stab.Frame.Origin + lbOffset;
|
||||
var worldRot = stab.Frame.Orientation;
|
||||
|
||||
// #190: never silently wrap past the counter budget — that's exactly how
|
||||
// this bug hid the first time (see the InteriorEntityIdAllocator doc
|
||||
// comment). Log once per landblock the moment it happens; the id below
|
||||
// still aliases into the next Y-slot when this fires (4096 is generous
|
||||
// but not infinite), but at least it's visible in launch.log instead of
|
||||
// silently breaking entity.Id-keyed systems (scripts, particles, shadows).
|
||||
if (localCounter == AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1)
|
||||
Console.WriteLine(
|
||||
$"[id-overflow] landblock 0x{landblockId:X8} exceeded {AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1} " +
|
||||
"interior entities — ids are now aliasing into the next landblock's reserved range (#190 class)");
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = interiorIdBase + localCounter++,
|
||||
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
|
||||
interiorLbX,
|
||||
interiorLbY,
|
||||
ref localCounter),
|
||||
SourceGfxObjOrSetupId = stab.Id,
|
||||
Position = worldPos,
|
||||
Rotation = worldRot,
|
||||
|
|
@ -7927,11 +7930,12 @@ public sealed class GameWindow : IDisposable
|
|||
// over time, and the stacked ties destabilize the 128-cap pool
|
||||
// sort). Clear the owner's previous registration first: the
|
||||
// re-apply becomes idempotent, and a first apply is a no-op.
|
||||
_lightingSink.UnregisterOwner(entity.Id);
|
||||
_translucencyFades.ClearEntity(entity.Id); // #188
|
||||
uint effectOwnerId = entity.Id;
|
||||
_lightingSink.UnregisterOwner(effectOwnerId);
|
||||
_translucencyFades.ClearEntity(effectOwnerId); // #188
|
||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
datSetup,
|
||||
ownerId: entity.Id,
|
||||
ownerId: effectOwnerId,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation,
|
||||
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||
|
|
@ -8212,6 +8216,13 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
||||
|
||||
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
|
||||
// Timer::cur_time at the packet/default-script call site. Publish this
|
||||
// update frame's clock before streaming and network dispatch, then
|
||||
// reuse the same timestamp for animation hooks and the later drain.
|
||||
_physicsScriptGameTime += Math.Max(0.0, dt);
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
|
||||
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
|
||||
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
|
||||
// boundary, independent of where in OnUpdate the applies landed) and reset.
|
||||
|
|
@ -9090,6 +9101,12 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
// so the renderer always sees the up-to-date per-part transforms.
|
||||
// PhysicsScript timed hooks and animation hooks read the same current
|
||||
// retail update-frame clock published before network dispatch. Drain
|
||||
// the script pass later after final root/part poses are composed.
|
||||
// Re-publish without advancing so an extra render between updates still
|
||||
// retains retail's animation-hook versus object-hook phase barrier.
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
_equippedChildRenderer?.Tick();
|
||||
|
|
@ -9259,7 +9276,8 @@ public sealed class GameWindow : IDisposable
|
|||
// debug-only and disabled for normal retail rendering.
|
||||
if (_options.EnableSkyPesDebug)
|
||||
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
|
||||
_scriptRunner?.Tick((float)deltaSeconds);
|
||||
_entityEffects?.RefreshLiveOwnerAnchors();
|
||||
_scriptRunner?.Tick(_physicsScriptGameTime);
|
||||
_particleSystem?.Tick((float)deltaSeconds);
|
||||
|
||||
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||
|
|
@ -14001,7 +14019,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
finally
|
||||
{
|
||||
_entityScriptActivator?.ClearLegacyPendingOwners();
|
||||
_entityEffects?.ClearNetworkState();
|
||||
}
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
|
|
|
|||
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of live effect profiles, mixed pre-materialization
|
||||
/// F754/F755 delivery, typed-table resolution, and effect-producing animation
|
||||
/// hooks. Server GUIDs are translated here; downstream sinks see local IDs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Network pending delivery follows retail
|
||||
/// <c>SmartBox::HandlePlayScriptID</c> (<c>0x00452020</c>) and
|
||||
/// <c>SmartBox::HandlePlayScriptType</c> (<c>0x00452070</c>):
|
||||
/// queue only while the object is absent; an existing cell-less object no-ops.
|
||||
/// Typed/default playback ports <c>CPhysicsObj::play_script</c>
|
||||
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
|
||||
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
|
||||
/// </remarks>
|
||||
public sealed class EntityEffectController : IAnimationHookSink
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly PhysicsScriptRunner _runner;
|
||||
private readonly PhysicsScriptTableResolver _tables;
|
||||
private readonly Func<uint, uint, uint?> _childAtPart;
|
||||
private readonly Func<uint, uint?> _parentOfAttachedChild;
|
||||
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, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||
private readonly HashSet<uint> _syntheticOwners = new();
|
||||
private Action<string>? _diagnosticSink = Console.WriteLine;
|
||||
|
||||
public EntityEffectController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsScriptRunner runner,
|
||||
PhysicsScriptTableResolver tables,
|
||||
Func<uint, uint, uint?>? childAtPart = null,
|
||||
Func<uint, uint?>? parentOfAttachedChild = null,
|
||||
Action<uint>? ownerUnregistered = null,
|
||||
Action<uint, uint?>? ownerSoundTableChanged = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
|
||||
_childAtPart = childAtPart ?? ((_, _) => null);
|
||||
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
|
||||
_ownerUnregistered = ownerUnregistered ?? (_ => { });
|
||||
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
|
||||
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
|
||||
}
|
||||
|
||||
public Action<string>? DiagnosticSink
|
||||
{
|
||||
get => _diagnosticSink;
|
||||
set => _diagnosticSink = value;
|
||||
}
|
||||
|
||||
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
|
||||
public int ReadyOwnerCount => _profilesByLocalId.Count;
|
||||
|
||||
public void HandleDirect(PlayPhysicsScript message)
|
||||
{
|
||||
if (message.Guid == 0)
|
||||
return;
|
||||
if (TryGetReadyLocalId(message.Guid, out uint localId))
|
||||
{
|
||||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayDirect(localId, message.ScriptDid);
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||
}
|
||||
|
||||
public void HandleTyped(PlayPhysicsScriptType message)
|
||||
{
|
||||
if (message.Guid == 0)
|
||||
return;
|
||||
if (TryGetReadyLocalId(message.Guid, out uint localId))
|
||||
{
|
||||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayTyped(localId, message.RawScriptType, message.Intensity);
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a live owner ready only after its projection, resource owners, and
|
||||
/// effect profile have all registered. Pending packets replay once in their
|
||||
/// original mixed F754/F755 order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !record.ResourcesRegistered
|
||||
|| record.EffectProfile is not EntityEffectProfile profile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_readyServerGuids.Add(serverGuid);
|
||||
_profilesByLocalId[entity.Id] = profile;
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
TryReplayPending(serverGuid, entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-publishes network-owned profile resources after a same-generation
|
||||
/// PhysicsDesc replacement. Retail <c>CPhysicsObj::set_description</c>
|
||||
/// (<c>0x00514F40</c>) releases and reinstalls <c>stable_id</c> on every
|
||||
/// description application, including present-zero clearing.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityDescriptionChanged(uint serverGuid)
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId)
|
||||
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.EffectProfile is not EntityEffectProfile profile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_profilesByLocalId[localId] = profile;
|
||||
_ownerSoundTableChanged(localId, profile.CurrentSoundTableDid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnDatStaticEntityReady(
|
||||
uint ownerLocalId,
|
||||
WorldEntity entity,
|
||||
EntityEffectProfile profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
if (ownerLocalId == 0)
|
||||
return;
|
||||
_staticOwners[ownerLocalId] = entity;
|
||||
_profilesByLocalId[ownerLocalId] = profile;
|
||||
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
|
||||
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
|
||||
}
|
||||
|
||||
public void OnDatStaticEntityRemoved(uint localId)
|
||||
{
|
||||
if (!_staticOwners.Remove(localId))
|
||||
return;
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
|
||||
public void RegisterSyntheticOwner(uint ownerLocalId)
|
||||
{
|
||||
if (ownerLocalId != 0)
|
||||
_syntheticOwners.Add(ownerLocalId);
|
||||
}
|
||||
|
||||
public void UnregisterSyntheticOwner(uint ownerLocalId) =>
|
||||
_syntheticOwners.Remove(ownerLocalId);
|
||||
|
||||
public void OnLiveEntityUnregistered(LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_pendingByServerGuid.Remove(record.ServerGuid);
|
||||
_readyServerGuids.Remove(record.ServerGuid);
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return;
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
|
||||
/// <summary>Clears a pending owner that never reached CreateObject.</summary>
|
||||
public void ForgetUnknownOwner(uint serverGuid) =>
|
||||
_pendingByServerGuid.Remove(serverGuid);
|
||||
|
||||
public void ClearNetworkState()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
|
||||
{
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
}
|
||||
_readyServerGuids.Clear();
|
||||
_pendingByServerGuid.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
|
||||
public void RefreshLiveOwnerAnchors()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||
continue;
|
||||
RefreshLiveAnchor(serverGuid, localId);
|
||||
TryReplayPending(serverGuid, localId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId))
|
||||
return false;
|
||||
return _runner.PlayDirect(ownerLocalId, scriptDid);
|
||||
}
|
||||
|
||||
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId)
|
||||
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)
|
||||
|| profile.CurrentPhysicsScriptTableDid is not { } tableDid)
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No PhysicsScriptTable for owner 0x{ownerLocalId:X8}, type 0x{rawScriptType:X8}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint? scriptDid = _tables.Resolve(
|
||||
tableDid,
|
||||
rawScriptType,
|
||||
intensity,
|
||||
out Exception? loadFailure);
|
||||
if (scriptDid is not { } resolved)
|
||||
{
|
||||
string detail = loadFailure is null
|
||||
? string.Empty
|
||||
: $" Load failed: {loadFailure.GetType().Name}: {loadFailure.Message}";
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No typed PhysicsScript for owner 0x{ownerLocalId:X8}, table 0x{tableDid:X8}, " +
|
||||
$"type 0x{rawScriptType:X8}, intensity {intensity:R}.{detail}");
|
||||
return false;
|
||||
}
|
||||
return _runner.PlayDirect(ownerLocalId, resolved);
|
||||
}
|
||||
|
||||
public bool PlayDefault(uint ownerLocalId)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId)
|
||||
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile))
|
||||
return false;
|
||||
return PlayTyped(
|
||||
ownerLocalId,
|
||||
profile.RawDefaultScriptType,
|
||||
profile.DefaultScriptIntensity);
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
_runner.SetOwnerAnchor(entityId, entityWorldPosition);
|
||||
switch (hook)
|
||||
{
|
||||
case CallPESHook call:
|
||||
if (CanStartOwner(entityId))
|
||||
_runner.ScheduleCallPes(entityId, call.PES, call.Pause);
|
||||
break;
|
||||
case DefaultScriptHook:
|
||||
PlayDefault(entityId);
|
||||
break;
|
||||
case DefaultScriptPartHook part:
|
||||
if (_childAtPart(entityId, part.PartIndex) is { } childLocalId)
|
||||
PlayDefault(childLocalId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) advances
|
||||
/// root scripts only with a cell and while not Frozen. Parented objects
|
||||
/// advance through their eligible parent's UpdateChild path instead of
|
||||
/// their own cell-less root update.
|
||||
/// </summary>
|
||||
public bool CanAdvanceOwner(uint ownerLocalId)
|
||||
{
|
||||
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
|
||||
return true;
|
||||
|
||||
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
|
||||
return CanAdvanceLiveRoot(parentLocalId);
|
||||
|
||||
return CanAdvanceLiveRoot(ownerLocalId);
|
||||
}
|
||||
|
||||
private bool CanStartOwner(uint ownerLocalId)
|
||||
{
|
||||
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
|
||||
return true;
|
||||
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
|
||||
return IsLiveRootInCell(parentLocalId);
|
||||
return IsLiveRootInCell(ownerLocalId);
|
||||
}
|
||||
|
||||
private bool CanAdvanceLiveRoot(uint ownerLocalId)
|
||||
{
|
||||
if (!TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record))
|
||||
return false;
|
||||
return IsLiveRootInCell(record)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||
}
|
||||
|
||||
private bool IsLiveRootInCell(uint ownerLocalId) =>
|
||||
TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record)
|
||||
&& IsLiveRootInCell(record);
|
||||
|
||||
private static bool IsLiveRootInCell(LiveEntityRecord record) =>
|
||||
record.ResourcesRegistered
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
|
||||
private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record)
|
||||
{
|
||||
if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid)
|
||||
&& _liveEntities.TryGetRecord(serverGuid, out record!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
|
||||
{
|
||||
if (_readyServerGuids.Contains(serverGuid)
|
||||
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
|
||||
&& _profilesByLocalId.ContainsKey(localId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
localId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RefreshLiveAnchor(uint serverGuid, uint localId)
|
||||
{
|
||||
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
&& record.WorldEntity is { } entity
|
||||
&& entity.Id == localId)
|
||||
{
|
||||
_runner.SetOwnerAnchor(localId, entity.Position);
|
||||
}
|
||||
}
|
||||
|
||||
private void Enqueue(uint serverGuid, PendingEffect effect)
|
||||
{
|
||||
if (!_pendingByServerGuid.TryGetValue(serverGuid, out Queue<PendingEffect>? queue))
|
||||
{
|
||||
queue = new Queue<PendingEffect>();
|
||||
_pendingByServerGuid.Add(serverGuid, queue);
|
||||
}
|
||||
queue.Enqueue(effect);
|
||||
}
|
||||
|
||||
private void TryReplayPending(uint serverGuid, uint localId)
|
||||
{
|
||||
if (!CanStartOwner(localId)
|
||||
|| !_pendingByServerGuid.Remove(serverGuid, out Queue<PendingEffect>? pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (pending.Count > 0)
|
||||
Execute(localId, pending.Dequeue());
|
||||
}
|
||||
|
||||
private void Execute(uint localId, PendingEffect effect)
|
||||
{
|
||||
if (effect.Kind is PendingEffectKind.Direct)
|
||||
PlayDirect(localId, effect.ScriptDid);
|
||||
else
|
||||
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
|
||||
}
|
||||
|
||||
private enum PendingEffectKind
|
||||
{
|
||||
Direct,
|
||||
Typed,
|
||||
}
|
||||
|
||||
private readonly record struct PendingEffect(
|
||||
PendingEffectKind Kind,
|
||||
uint ScriptDid,
|
||||
uint RawScriptType,
|
||||
float Intensity)
|
||||
{
|
||||
public static PendingEffect Direct(uint scriptDid) =>
|
||||
new(PendingEffectKind.Direct, scriptDid, 0u, 0f);
|
||||
|
||||
public static PendingEffect Typed(uint rawScriptType, float intensity) =>
|
||||
new(PendingEffectKind.Typed, 0u, rawScriptType, intensity);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,12 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
|
|||
{
|
||||
SetupDefaultScriptDid = NormalizePhysicsScriptDid(setup.DefaultScript.DataId);
|
||||
CurrentPhysicsScriptTableDid = NormalizeTableDid((uint)setup.DefaultScriptTable);
|
||||
CurrentSoundTableDid = NormalizeSoundTableDid((uint)setup.DefaultSoundTable);
|
||||
}
|
||||
|
||||
public uint? SetupDefaultScriptDid { get; }
|
||||
public uint? CurrentPhysicsScriptTableDid { get; private set; }
|
||||
public uint? CurrentSoundTableDid { get; private set; }
|
||||
public uint RawDefaultScriptType { get; private set; }
|
||||
public float DefaultScriptIntensity { get; private set; }
|
||||
public bool HasNetworkDescription { get; private set; }
|
||||
|
|
@ -60,6 +62,8 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
|
|||
{
|
||||
CurrentPhysicsScriptTableDid = NormalizeTableDid(
|
||||
physics.PhysicsScriptTableId.GetValueOrDefault());
|
||||
CurrentSoundTableDid = NormalizeSoundTableDid(
|
||||
physics.SoundTableId.GetValueOrDefault());
|
||||
RawDefaultScriptType = physics.DefaultScriptType.GetValueOrDefault();
|
||||
DefaultScriptIntensity = physics.DefaultScriptIntensity.GetValueOrDefault();
|
||||
HasNetworkDescription = true;
|
||||
|
|
@ -70,4 +74,7 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
|
|||
|
||||
private static uint? NormalizeTableDid(uint did) =>
|
||||
PhysicsScriptTableResolver.IsPhysicsScriptTableDid(did) ? did : null;
|
||||
|
||||
private static uint? NormalizeSoundTableDid(uint did) =>
|
||||
(did & 0xFF000000u) == 0x20000000u ? did : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ namespace AcDream.App.Rendering.Vfx;
|
|||
/// </summary>
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms);
|
||||
IReadOnlyList<Matrix4x4> PartTransforms,
|
||||
EntityEffectProfile? EffectProfile = null);
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
|
|
@ -27,8 +28,9 @@ public sealed record ScriptActivationInfo(
|
|||
/// Stops the scripts and live emitters when the entity despawns.
|
||||
///
|
||||
/// <para>
|
||||
/// Handles both server-spawned and dat-hydrated entities, always keyed by
|
||||
/// canonical local <c>entity.Id</c>. The C.1.5a guard that early-returned for
|
||||
/// Handles both server-spawned and dat-hydrated entities. Live owners use the
|
||||
/// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
|
||||
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
|
||||
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
|
||||
/// (which have no server guid because they come from the dat file, not
|
||||
/// the network) also fire their DefaultScript.
|
||||
|
|
@ -52,11 +54,12 @@ public sealed class EntityScriptActivator
|
|||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
private readonly HashSet<uint> _legacyPendingOwners = new();
|
||||
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
|
||||
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
|
||||
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
|
||||
|
||||
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
||||
/// (scriptId, entityId) instance table and schedules hooks at their
|
||||
/// <c>StartTime</c> offsets.</param>
|
||||
/// <param name="scriptRunner">Per-owner retail FIFO that schedules every
|
||||
/// hook at its absolute script <c>StartTime</c>.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator pushes per-entity rotation + part transforms here, and
|
||||
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
|
||||
|
|
@ -70,7 +73,9 @@ public sealed class EntityScriptActivator
|
|||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver)
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver,
|
||||
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
|
||||
Action<uint>? unregisterDatStaticEffectOwner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
|
|
@ -78,11 +83,13 @@ public sealed class EntityScriptActivator
|
|||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_resolver = resolver;
|
||||
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
|
||||
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner, keyed by canonical local <c>entity.Id</c>.
|
||||
/// the script runner under its canonical runtime identity.
|
||||
/// No-op if the entity has no DefaultScript (resolver returns null
|
||||
/// or zero-script).
|
||||
/// </summary>
|
||||
|
|
@ -91,9 +98,17 @@ public sealed class EntityScriptActivator
|
|||
ArgumentNullException.ThrowIfNull(entity);
|
||||
uint key = entity.Id;
|
||||
if (key == 0) return; // malformed entity
|
||||
if (entity.ServerGuid == 0
|
||||
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
|
||||
{
|
||||
if (ReferenceEquals(existing, entity))
|
||||
return;
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
|
||||
}
|
||||
|
||||
var info = _resolver(entity);
|
||||
if (info is null || info.ScriptId == 0) return;
|
||||
if (info is null) return;
|
||||
|
||||
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
|
||||
// (in entity-local frame) transforms correctly to world space when the
|
||||
|
|
@ -109,63 +124,39 @@ public sealed class EntityScriptActivator
|
|||
// in a multi-part Setup collapses to the entity root.
|
||||
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
|
||||
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
if (entity.ServerGuid == 0)
|
||||
_activeStaticOwners.Add(key, entity);
|
||||
|
||||
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
|
||||
_registerDatStaticEffectOwner?.Invoke(
|
||||
key,
|
||||
entity,
|
||||
profile);
|
||||
|
||||
if (info.ScriptId != 0)
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this key, and
|
||||
/// kill every live emitter the sink has attributed to the canonical
|
||||
/// local entity id. Idempotent for unknown keys.
|
||||
/// kill every live emitter the sink has attributed to the runtime effect
|
||||
/// owner. Idempotent for unknown entities.
|
||||
/// </summary>
|
||||
public void OnRemove(uint key)
|
||||
public void OnRemove(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
uint key = entity.Id;
|
||||
if (key == 0) return;
|
||||
if (entity.ServerGuid == 0
|
||||
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|
||||
|| !ReferenceEquals(existing, entity)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the temporary server-GUID owner used when F754 precedes the
|
||||
/// target's CreateObject/materialization. Step 4 replaces this with the
|
||||
/// retail mixed pending-packet FIFO. Tracking is required because no
|
||||
/// LiveEntityRecord may exist yet for delete/session teardown.
|
||||
/// </summary>
|
||||
public bool PlayLegacyPending(
|
||||
uint serverGuid,
|
||||
uint scriptDid,
|
||||
Vector3 anchorWorldPosition)
|
||||
{
|
||||
if (serverGuid == 0)
|
||||
return false;
|
||||
bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition);
|
||||
if (played)
|
||||
_legacyPendingOwners.Add(serverGuid);
|
||||
return played;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the temporary server-GUID owner used when F754 arrives before a
|
||||
/// live projection has a canonical local ID. Step 4 replaces this alias
|
||||
/// with the retail mixed pending-packet FIFO; until then teardown must stop
|
||||
/// the alias as well as the normal local owner so an early effect cannot
|
||||
/// outlive its object incarnation.
|
||||
/// </summary>
|
||||
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
|
||||
{
|
||||
if (serverGuid == 0 || serverGuid == canonicalLocalId)
|
||||
return;
|
||||
if (!_legacyPendingOwners.Remove(serverGuid))
|
||||
return;
|
||||
OnRemove(serverGuid);
|
||||
}
|
||||
|
||||
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
|
||||
public void ClearLegacyPendingOwners()
|
||||
{
|
||||
foreach (uint serverGuid in _legacyPendingOwners)
|
||||
OnRemove(serverGuid);
|
||||
_legacyPendingOwners.Clear();
|
||||
}
|
||||
|
||||
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ public sealed class GpuWorldState
|
|||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
if (entity.ServerGuid == 0)
|
||||
_entityScriptActivator.OnRemove(entity.Id);
|
||||
_entityScriptActivator.OnRemove(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -549,7 +549,7 @@ public sealed class GpuWorldState
|
|||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
if (entity.ServerGuid == 0)
|
||||
_entityScriptActivator.OnRemove(entity.Id);
|
||||
_entityScriptActivator.OnRemove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -604,15 +604,18 @@ public sealed class LiveEntityRuntime
|
|||
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Retail runs root movement/animation only while the object has world-cell
|
||||
/// membership. A pickup or parent transition keeps script/effect owners but
|
||||
/// clears the cell and withdraws the root projection until a fresh Position
|
||||
/// re-enters it.
|
||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
||||
/// movement/animation only while the object has visible world-cell
|
||||
/// membership and is not Frozen. A pickup or parent transition keeps
|
||||
/// script/effect owners but clears the cell and withdraws the root
|
||||
/// projection until a fresh Position re-enters it.
|
||||
/// </summary>
|
||||
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.FullCellId != 0;
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Claims the one logical top-level spawn notification for this object
|
||||
|
|
|
|||
|
|
@ -82,9 +82,13 @@ public sealed class RetailPhysicsScriptLoader
|
|||
uint count = reader.ReadUInt32();
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
double startTime = reader.ReadDouble();
|
||||
if (!double.IsFinite(startTime))
|
||||
throw new InvalidDataException(
|
||||
$"PhysicsScript 0x{script.Id:X8} hook {i} has non-finite StartTime {startTime}.");
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = reader.ReadDouble(),
|
||||
StartTime = startTime,
|
||||
Hook = RetailAnimationHookReader.Read(reader),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
_system.StopEmitter(handleToStop, fadeOut: true);
|
||||
break;
|
||||
|
||||
// DefaultScript / CallPES are routed by the entity-effect owner in
|
||||
// Step 4. ParticleHookSink intentionally owns particles only.
|
||||
// DefaultScript / CallPES are routed by EntityEffectController.
|
||||
// ParticleHookSink intentionally owns particles only.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,273 +1,400 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in
|
||||
// VfxModel.cs; alias the dat-reader type to avoid name collision.
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime —
|
||||
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by
|
||||
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script
|
||||
/// start). Every visible effect the server triggers via the
|
||||
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
|
||||
/// spell casts, emote gestures, combat flinches, AND — per the
|
||||
/// 2026-04-23 lightning research — weather lightning flashes.
|
||||
///
|
||||
/// <para>
|
||||
/// Decompile provenance (see
|
||||
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
|
||||
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
|
||||
/// public API: resolves the dat id, allocates a script node, inserts
|
||||
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
|
||||
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
|
||||
/// schedules the next fire time based on the next hook's
|
||||
/// <c>StartTime</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
|
||||
/// <c>head.NextHookAbsTime <= globalClock</c>, fire hooks via
|
||||
/// vtable dispatch on the owner <c>PhysicsObj</c>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Design choices vs retail:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Flat list, not a linked list — iteration is
|
||||
/// simpler and N is small (< 100 active scripts in practice).
|
||||
/// </description></item>
|
||||
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
|
||||
/// — same pair re-played replaces the old instance so we don't
|
||||
/// stack duplicates when the server retriggers.
|
||||
/// </description></item>
|
||||
/// <item><description>The anchor world position is cached at spawn
|
||||
/// time. For long-running scripts on moving entities, the caller
|
||||
/// can <see cref="Play"/> again with a fresh position each
|
||||
/// frame — idempotent.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// Retail PhysicsScript scheduler: one serial FIFO per owning physics object.
|
||||
/// Duplicate plays append, different owners progress independently, and a
|
||||
/// catch-up tick drains every due hook in order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port of <c>ScriptManager::AddScriptInternal</c> (<c>0x0051B310</c>),
|
||||
/// <c>ScriptManager::NextHook</c> (<c>0x0051B3F0</c>), and
|
||||
/// <c>ScriptManager::UpdateScripts</c> (<c>0x0051B480</c>).
|
||||
/// Delayed nested calls follow <c>CPhysicsObj::CallPES</c>
|
||||
/// (<c>0x00511AF0</c>).
|
||||
/// </remarks>
|
||||
public sealed class PhysicsScriptRunner
|
||||
{
|
||||
public const float ImmediateCallPesThresholdSeconds = 0.0002f;
|
||||
|
||||
private readonly Func<uint, DatPhysicsScript?> _resolver;
|
||||
private readonly IAnimationHookSink _sink;
|
||||
private readonly Func<double> _clock;
|
||||
private readonly Func<double> _randomUnit;
|
||||
private readonly Func<uint, bool> _canAdvanceOwner;
|
||||
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
|
||||
private readonly Dictionary<uint, OwnerQueue> _owners = new();
|
||||
private readonly Dictionary<uint, Vector3> _ownerAnchors = new();
|
||||
private readonly List<DelayedCallPes> _delayedCalls = new();
|
||||
private double _gameTime;
|
||||
private long _tickGeneration;
|
||||
private bool _frameTimePublished;
|
||||
private bool _processingTimedHooks;
|
||||
private DispatchContext? _dispatchContext;
|
||||
|
||||
// One active node per (scriptId, entityId) pair. Replaying replaces.
|
||||
private readonly List<ActiveScript> _active = new();
|
||||
private double _now; // absolute runtime in seconds
|
||||
|
||||
/// <summary>
|
||||
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment,
|
||||
/// every <see cref="Play"/> call and every hook fire prints a line
|
||||
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
|
||||
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
|
||||
/// and which script IDs those are. Off by default.
|
||||
/// </summary>
|
||||
public bool DiagEnabled { get; set; } =
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Preferred ctor — resolver delegate lets this class stay
|
||||
/// DAT-reader-free for testing. Production passes the centralized retail
|
||||
/// compatibility loader so CreateBlockingParticle cannot misalign hooks.
|
||||
/// </summary>
|
||||
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
|
||||
public PhysicsScriptRunner(
|
||||
Func<uint, DatPhysicsScript?> resolver,
|
||||
IAnimationHookSink sink,
|
||||
Func<double>? clock = null,
|
||||
Func<double>? randomUnit = null,
|
||||
Func<uint, bool>? canAdvanceOwner = null)
|
||||
{
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_clock = clock ?? (() => _gameTime);
|
||||
_randomUnit = randomUnit ?? Random.Shared.NextDouble;
|
||||
_canAdvanceOwner = canAdvanceOwner ?? (_ => true);
|
||||
}
|
||||
|
||||
/// <summary>Number of scripts currently active (for telemetry).</summary>
|
||||
public int ActiveScriptCount => _active.Count;
|
||||
/// <summary>Queued PhysicsScripts across every owner, including tails.</summary>
|
||||
public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Start (or restart) a PhysicsScript on the given entity.
|
||||
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
|
||||
/// <c>true</c> if the script was found and queued, <c>false</c>
|
||||
/// if the dat lookup failed. Replaying the same
|
||||
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
|
||||
/// instead of stacking.
|
||||
/// </summary>
|
||||
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
|
||||
public int ActiveOwnerCount => _owners.Count;
|
||||
public int ScheduledCallPesCount => _delayedCalls.Count;
|
||||
|
||||
public bool DiagEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
|
||||
|
||||
/// <summary>Always-on structural/load diagnostics; production supplies a logger.</summary>
|
||||
public Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
/// <summary>Updates the root anchor used when this owner's hooks dispatch.</summary>
|
||||
public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition)
|
||||
{
|
||||
if (scriptId == 0) return false;
|
||||
if (ownerLocalId != 0)
|
||||
_ownerAnchors[ownerLocalId] = worldPosition;
|
||||
}
|
||||
|
||||
var script = ResolveScript(scriptId);
|
||||
/// <summary>Enqueues a direct F754/Setup PhysicsScript DID.</summary>
|
||||
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
|
||||
{
|
||||
if (ownerLocalId == 0 || scriptDid == 0)
|
||||
return false;
|
||||
|
||||
DatPhysicsScript? script = ResolveScript(scriptDid);
|
||||
if (script is null || script.ScriptData.Count == 0)
|
||||
{
|
||||
ReportDiagnostic(
|
||||
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} is missing or empty.");
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] Play: script 0x{scriptId:X8} not found / empty");
|
||||
Console.WriteLine($"[pes] missing/empty script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8}");
|
||||
return false;
|
||||
}
|
||||
if (script.ScriptData.Any(entry => !double.IsFinite(entry.StartTime)))
|
||||
{
|
||||
ReportDiagnostic(
|
||||
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} has a non-finite hook time.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dedupe: if this (scriptId, entityId) already has an active
|
||||
// instance, replace it — retail's ScriptManager doesn't
|
||||
// double-schedule the same script on the same object in the
|
||||
// common path.
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
if (!_owners.TryGetValue(ownerLocalId, out OwnerQueue? owner))
|
||||
{
|
||||
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
owner = new OwnerQueue();
|
||||
_owners.Add(ownerLocalId, owner);
|
||||
}
|
||||
|
||||
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0);
|
||||
double start = owner.Scripts.Count == 0
|
||||
? _clock()
|
||||
: owner.Scripts[^1].StartTime + owner.Scripts[^1].Duration;
|
||||
if (!double.IsFinite(start))
|
||||
{
|
||||
ReportDiagnostic(
|
||||
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} produced a non-finite start time.");
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<uint>? immediateAncestors = null;
|
||||
if (_dispatchContext is { } dispatch
|
||||
&& dispatch.OwnerLocalId == ownerLocalId
|
||||
&& start <= dispatch.Script.StartTime)
|
||||
{
|
||||
immediateAncestors = dispatch.Script.ImmediateAncestors is null
|
||||
? new HashSet<uint> { dispatch.Script.ScriptDid }
|
||||
: new HashSet<uint>(dispatch.Script.ImmediateAncestors) { dispatch.Script.ScriptDid };
|
||||
if (immediateAncestors.Contains(scriptDid))
|
||||
{
|
||||
ReportDiagnostic(
|
||||
$"Rejected zero-time recursive PhysicsScript 0x{scriptDid:X8} for owner " +
|
||||
$"0x{ownerLocalId:X8}; DAT CallPES chain would never yield.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
owner.Scripts.Add(new ScheduledScript(
|
||||
scriptDid,
|
||||
script,
|
||||
start,
|
||||
immediateAncestors,
|
||||
EarliestScriptTickGeneration(ownerLocalId)));
|
||||
|
||||
if (DiagEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pes] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " +
|
||||
$"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " +
|
||||
$"hooks={script.ScriptData.Count}");
|
||||
}
|
||||
Console.WriteLine($"[pes] enqueue script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8} start={start:R} depth={owner.Scripts.Count}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddActiveScript(
|
||||
DatPhysicsScript script,
|
||||
uint scriptId,
|
||||
uint entityId,
|
||||
Vector3 anchorWorldPos,
|
||||
float delaySeconds)
|
||||
/// <summary>
|
||||
/// Compatibility seam for existing static/sky callers. The scheduler is
|
||||
/// still keyed by canonical owner ID; the position merely refreshes its
|
||||
/// hook anchor before enqueue.
|
||||
/// </summary>
|
||||
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
|
||||
{
|
||||
_active.Add(new ActiveScript
|
||||
{
|
||||
Script = script,
|
||||
ScriptId = scriptId,
|
||||
EntityId = entityId,
|
||||
AnchorWorld = anchorWorldPos,
|
||||
StartTimeAbs = _now + Math.Max(0f, delaySeconds),
|
||||
NextHookIndex = 0,
|
||||
});
|
||||
SetOwnerAnchor(entityId, anchorWorldPos);
|
||||
return PlayDirect(entityId, scriptId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every active script by <paramref name="dtSeconds"/>.
|
||||
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
|
||||
/// (measured from the script's <see cref="Play"/> moment) has been
|
||||
/// reached. Removes scripts that have finished all their hooks.
|
||||
/// Implements retail CallPES. Near-zero pauses append immediately; a
|
||||
/// positive pause samples a deterministic injectable uniform delay in
|
||||
/// <c>[0, maximumPause]</c> and retains the call until due.
|
||||
/// </summary>
|
||||
public void Tick(float dtSeconds)
|
||||
public bool ScheduleCallPes(uint ownerLocalId, uint scriptDid, float maximumPause)
|
||||
{
|
||||
if (dtSeconds < 0) dtSeconds = 0;
|
||||
_now += dtSeconds;
|
||||
if (ownerLocalId == 0 || scriptDid == 0 || !float.IsFinite(maximumPause))
|
||||
return false;
|
||||
if (maximumPause < ImmediateCallPesThresholdSeconds)
|
||||
return PlayDirect(ownerLocalId, scriptDid);
|
||||
|
||||
// Back-to-front so RemoveAt() is cheap and safe mid-iteration.
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
double sample = _randomUnit();
|
||||
if (!double.IsFinite(sample))
|
||||
return false;
|
||||
sample = Math.Clamp(sample, 0.0, 1.0);
|
||||
double due = _clock() + sample * maximumPause;
|
||||
_delayedCalls.Add(new DelayedCallPes(
|
||||
ownerLocalId,
|
||||
scriptDid,
|
||||
due,
|
||||
EarliestTickGenerationForNewTimedHook()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the current frame clock before animation hooks are delivered,
|
||||
/// without running retail's timed-hook/script update pass.
|
||||
/// </summary>
|
||||
public void PublishTime(double gameTime)
|
||||
{
|
||||
ValidateMonotonicTime(gameTime);
|
||||
_gameTime = gameTime;
|
||||
_frameTimePublished = true;
|
||||
}
|
||||
|
||||
/// <summary>Advances to an absolute game-clock timestamp.</summary>
|
||||
public void Tick(double gameTime)
|
||||
{
|
||||
ValidateMonotonicTime(gameTime);
|
||||
_gameTime = gameTime;
|
||||
_frameTimePublished = false;
|
||||
_tickGeneration++;
|
||||
|
||||
DrainDueCallPes(gameTime);
|
||||
|
||||
// Snapshot owner IDs. Hooks may append to their current owner, create a
|
||||
// different owner, or delete an owner without invalidating iteration.
|
||||
uint[] ownerIds = _owners.Keys.ToArray();
|
||||
for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++)
|
||||
{
|
||||
var a = _active[i];
|
||||
double elapsed = _now - a.StartTimeAbs;
|
||||
uint ownerId = ownerIds[ownerIndex];
|
||||
if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner))
|
||||
continue;
|
||||
if (!_canAdvanceOwner(ownerId))
|
||||
continue;
|
||||
DrainOwner(ownerId, owner, gameTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire every hook whose scheduled time has arrived.
|
||||
while (a.NextHookIndex < a.Script.ScriptData.Count
|
||||
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed)
|
||||
/// <summary>Compatibility delta-time overload used by existing callers.</summary>
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
if (deltaSeconds < 0f)
|
||||
deltaSeconds = 0f;
|
||||
Tick(_gameTime + deltaSeconds);
|
||||
}
|
||||
|
||||
public void StopAllForEntity(uint ownerLocalId)
|
||||
{
|
||||
_owners.Remove(ownerLocalId);
|
||||
_ownerAnchors.Remove(ownerLocalId);
|
||||
_delayedCalls.RemoveAll(call => call.OwnerLocalId == ownerLocalId);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_owners.Clear();
|
||||
_ownerAnchors.Clear();
|
||||
_delayedCalls.Clear();
|
||||
}
|
||||
|
||||
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(script);
|
||||
_scriptCache[id] = script;
|
||||
}
|
||||
|
||||
private void DrainDueCallPes(double gameTime)
|
||||
{
|
||||
// CPhysicsObj inserts timed object hooks at the head. Traversing in
|
||||
// reverse insertion order reproduces newest-first observation.
|
||||
_processingTimedHooks = true;
|
||||
try
|
||||
{
|
||||
for (int i = _delayedCalls.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = a.Script.ScriptData[a.NextHookIndex];
|
||||
DispatchHook(a, entry.Hook);
|
||||
a.NextHookIndex++;
|
||||
DelayedCallPes call = _delayedCalls[i];
|
||||
if (call.DueTime > gameTime
|
||||
|| call.EarliestTickGeneration > _tickGeneration
|
||||
|| !_canAdvanceOwner(call.OwnerLocalId))
|
||||
continue;
|
||||
_delayedCalls.RemoveAt(i);
|
||||
PlayDirect(call.OwnerLocalId, call.ScriptDid);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processingTimedHooks = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainOwner(uint ownerId, OwnerQueue owner, double gameTime)
|
||||
{
|
||||
while (owner.Scripts.Count > 0)
|
||||
{
|
||||
ScheduledScript current = owner.Scripts[0];
|
||||
if (current.EarliestTickGeneration > _tickGeneration)
|
||||
return;
|
||||
while (current.NextHookIndex < current.Script.ScriptData.Count)
|
||||
{
|
||||
PhysicsScriptData entry = current.Script.ScriptData[current.NextHookIndex];
|
||||
if (current.StartTime + entry.StartTime > gameTime)
|
||||
break;
|
||||
|
||||
current.NextHookIndex++;
|
||||
DispatchHook(ownerId, current, entry.Hook);
|
||||
|
||||
// A hook callback may delete the owner. Never continue through
|
||||
// the detached queue object after logical teardown.
|
||||
if (!_owners.TryGetValue(ownerId, out OwnerQueue? currentOwner)
|
||||
|| !ReferenceEquals(currentOwner, owner))
|
||||
return;
|
||||
}
|
||||
|
||||
if (a.NextHookIndex >= a.Script.ScriptData.Count)
|
||||
_active.RemoveAt(i);
|
||||
else
|
||||
_active[i] = a;
|
||||
if (current.NextHookIndex < current.Script.ScriptData.Count)
|
||||
return;
|
||||
|
||||
owner.Scripts.RemoveAt(0);
|
||||
}
|
||||
|
||||
_owners.Remove(ownerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop an active script instance by
|
||||
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
|
||||
/// despawns. Not necessary to call on normal script completion —
|
||||
/// scripts self-remove via <see cref="Tick"/>.
|
||||
/// </summary>
|
||||
public void Stop(uint scriptId, uint entityId)
|
||||
{
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
|
||||
public void StopAllForEntity(uint entityId)
|
||||
{
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchHook(ActiveScript a, AnimationHook hook)
|
||||
private void DispatchHook(uint ownerId, ScheduledScript script, AnimationHook hook)
|
||||
{
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] fire script=0x{script.ScriptDid:X8} owner=0x{ownerId:X8} hook={hook.HookType}");
|
||||
|
||||
_ownerAnchors.TryGetValue(ownerId, out Vector3 anchor);
|
||||
DispatchContext? previous = _dispatchContext;
|
||||
_dispatchContext = new DispatchContext(ownerId, script);
|
||||
try
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
|
||||
$"hook={hook.HookType}");
|
||||
_sink.OnHook(ownerId, anchor, hook);
|
||||
}
|
||||
|
||||
// Handle the nested-script hook inline — it needs our runner.
|
||||
// Everything else delegates to the sink (ParticleHookSink
|
||||
// handles CreateParticle, DestroyParticle, StopParticle,
|
||||
// CreateBlockingParticle, etc).
|
||||
if (hook is CallPESHook call)
|
||||
finally
|
||||
{
|
||||
// CallPESHook.PES = sub-script id; Pause = delay before the
|
||||
// sub-script starts. Retail links it into the active script
|
||||
// list with StartTime = now + Pause; our flat list preserves
|
||||
// that timing without replacing the currently running script.
|
||||
var subScript = ResolveScript(call.PES);
|
||||
if (subScript is null || subScript.ScriptData.Count == 0)
|
||||
{
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty");
|
||||
return;
|
||||
}
|
||||
|
||||
AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause);
|
||||
return;
|
||||
_dispatchContext = previous;
|
||||
}
|
||||
|
||||
_sink.OnHook(a.EntityId, a.AnchorWorld, hook);
|
||||
}
|
||||
|
||||
private DatPhysicsScript? ResolveScript(uint id)
|
||||
{
|
||||
if (_scriptCache.TryGetValue(id, out var cached)) return cached;
|
||||
var script = _resolver(id);
|
||||
if (_scriptCache.TryGetValue(id, out DatPhysicsScript? cached))
|
||||
return cached;
|
||||
DatPhysicsScript? script;
|
||||
try
|
||||
{
|
||||
script = _resolver(id);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ReportDiagnostic($"Failed to load PhysicsScript 0x{id:X8}: {error.Message}");
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] load failed script=0x{id:X8}: {error.Message}");
|
||||
script = null;
|
||||
}
|
||||
_scriptCache[id] = script;
|
||||
return script;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam: pre-seed the resolver cache with a hand-built
|
||||
/// script so unit tests can exercise the scheduler without loading
|
||||
/// dats. Production code never calls this (name carries the warning).
|
||||
/// </summary>
|
||||
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
|
||||
=> _scriptCache[id] = script;
|
||||
|
||||
private struct ActiveScript
|
||||
private long EarliestTickGenerationForNewTimedHook()
|
||||
{
|
||||
public DatPhysicsScript Script;
|
||||
public uint ScriptId;
|
||||
public uint EntityId;
|
||||
public Vector3 AnchorWorld;
|
||||
public double StartTimeAbs;
|
||||
public int NextHookIndex;
|
||||
// A positive-pause CallPES adds a timed object hook. Retail does not
|
||||
// revisit the active process_hooks traversal for a hook inserted by
|
||||
// the animation phase, even when its sampled delay is zero. Near-zero
|
||||
// CallPES bypasses this list and may reach the upcoming script pass.
|
||||
return _frameTimePublished
|
||||
? _tickGeneration + 2
|
||||
: _tickGeneration + 1;
|
||||
}
|
||||
|
||||
private long EarliestScriptTickGeneration(uint ownerLocalId)
|
||||
{
|
||||
if (_processingTimedHooks
|
||||
|| _dispatchContext is { OwnerLocalId: var dispatchOwner }
|
||||
&& dispatchOwner == ownerLocalId)
|
||||
return _tickGeneration;
|
||||
return _tickGeneration + 1;
|
||||
}
|
||||
|
||||
private void ValidateMonotonicTime(double gameTime)
|
||||
{
|
||||
if (!double.IsFinite(gameTime))
|
||||
throw new ArgumentOutOfRangeException(nameof(gameTime));
|
||||
if (gameTime < _gameTime)
|
||||
throw new ArgumentOutOfRangeException(nameof(gameTime), "PhysicsScript time must be monotonic.");
|
||||
}
|
||||
|
||||
private void ReportDiagnostic(string message) => DiagnosticSink?.Invoke(message);
|
||||
|
||||
private sealed class OwnerQueue
|
||||
{
|
||||
public List<ScheduledScript> Scripts { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class ScheduledScript
|
||||
{
|
||||
public ScheduledScript(
|
||||
uint scriptDid,
|
||||
DatPhysicsScript script,
|
||||
double startTime,
|
||||
HashSet<uint>? immediateAncestors,
|
||||
long earliestTickGeneration)
|
||||
{
|
||||
ScriptDid = scriptDid;
|
||||
Script = script;
|
||||
StartTime = startTime;
|
||||
Duration = script.ScriptData[^1].StartTime;
|
||||
ImmediateAncestors = immediateAncestors;
|
||||
EarliestTickGeneration = earliestTickGeneration;
|
||||
}
|
||||
|
||||
public uint ScriptDid { get; }
|
||||
public DatPhysicsScript Script { get; }
|
||||
public double StartTime { get; }
|
||||
public double Duration { get; }
|
||||
public HashSet<uint>? ImmediateAncestors { get; }
|
||||
public long EarliestTickGeneration { get; }
|
||||
public int NextHookIndex { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct DelayedCallPes(
|
||||
uint OwnerLocalId,
|
||||
uint ScriptDid,
|
||||
double DueTime,
|
||||
long EarliestTickGeneration);
|
||||
|
||||
private sealed record DispatchContext(uint OwnerLocalId, ScheduledScript Script);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,33 @@ public sealed class PhysicsScriptTableResolver
|
|||
/// type, an unordered/above-range intensity, or an invalid script DID.
|
||||
/// </summary>
|
||||
public uint? Resolve(uint tableDid, uint rawScriptType, float intensity)
|
||||
=> Resolve(tableDid, rawScriptType, intensity, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a typed play while preserving a corrupt-DAT exception for the
|
||||
/// App owner to diagnose with entity context. A load failure remains a
|
||||
/// retail no-play and never corrupts the caller's script FIFO.
|
||||
/// </summary>
|
||||
public uint? Resolve(
|
||||
uint tableDid,
|
||||
uint rawScriptType,
|
||||
float intensity,
|
||||
out Exception? loadFailure)
|
||||
{
|
||||
loadFailure = null;
|
||||
if (!IsPhysicsScriptTableDid(tableDid))
|
||||
return null;
|
||||
|
||||
PhysicsScriptTable? table = _loadTable(tableDid);
|
||||
PhysicsScriptTable? table;
|
||||
try
|
||||
{
|
||||
table = _loadTable(tableDid);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
loadFailure = error;
|
||||
return null;
|
||||
}
|
||||
if (table is null
|
||||
|| table.Id != tableDid
|
||||
|| !table.ScriptTable.TryGetValue(
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ public static class InteriorEntityIdAllocator
|
|||
{
|
||||
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
|
||||
/// more interior entities than this would alias into the next Y-slot —
|
||||
/// exactly the #190 bug, just at a higher threshold. Callers should log
|
||||
/// loudly (never silently wrap) if this is ever exceeded.</summary>
|
||||
/// exactly the #190 bug, just at a higher threshold. <see cref="Allocate"/>
|
||||
/// fails before that can happen.</summary>
|
||||
public const uint MaxCounter = 0xFFFu;
|
||||
|
||||
/// <summary>The first id in this landblock's reserved range (counter=0).
|
||||
|
|
@ -46,4 +46,18 @@ public static class InteriorEntityIdAllocator
|
|||
/// within the landblock.</summary>
|
||||
public static uint Base(uint landblockX, uint landblockY)
|
||||
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
|
||||
|
||||
/// <summary>
|
||||
/// Allocates the next canonical interior-static ID and advances
|
||||
/// <paramref name="counter"/>. The full 12-bit range, including
|
||||
/// <see cref="MaxCounter"/>, is valid; the next request fails rather than
|
||||
/// aliasing the following landblock.
|
||||
/// </summary>
|
||||
public static uint Allocate(uint landblockX, uint landblockY, ref uint counter)
|
||||
{
|
||||
if (counter > MaxCounter)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry interior entity id namespace.");
|
||||
return Base(landblockX, landblockY) + counter++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,22 +47,29 @@ public static class LandblockLoader
|
|||
// legacy starting-from-1 monotonic Ids — compatible with their assertions
|
||||
// which check uniqueness within a single landblock.
|
||||
//
|
||||
// Latent: if a landblock has >256 stabs (rare), nextId overflows the
|
||||
// low byte and bleeds into the lbY byte → cross-LB collision. Same
|
||||
// pattern + same limitation as scenery/interior. Document but don't
|
||||
// fix in this commit — out of scope for the Tier 1 cache bug fix.
|
||||
// The low byte reserves 0 for the namespace base and 1..255 for
|
||||
// entities. Never wrap into the Y byte: a collision here would corrupt
|
||||
// every renderer/physics/effect table keyed by WorldEntity.Id.
|
||||
uint stabIdBase = landblockId == 0
|
||||
? 0u
|
||||
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
|
||||
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u;
|
||||
|
||||
uint AllocateId()
|
||||
{
|
||||
if (stabIdBase != 0 && nextId > stabIdBase + 0xFFu)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock 0x{landblockId:X8} exceeds the 255-entry static stab id namespace.");
|
||||
return nextId++;
|
||||
}
|
||||
|
||||
foreach (var stab in info.Objects)
|
||||
{
|
||||
if (!IsSupported(stab.Id))
|
||||
continue;
|
||||
var stabEntity = new WorldEntity
|
||||
{
|
||||
Id = nextId++,
|
||||
Id = AllocateId(),
|
||||
SourceGfxObjOrSetupId = stab.Id,
|
||||
Position = stab.Frame.Origin,
|
||||
Rotation = stab.Frame.Orientation,
|
||||
|
|
@ -78,7 +85,7 @@ public static class LandblockLoader
|
|||
continue;
|
||||
var buildingEntity = new WorldEntity
|
||||
{
|
||||
Id = nextId++,
|
||||
Id = AllocateId(),
|
||||
SourceGfxObjOrSetupId = building.ModelId,
|
||||
Position = building.Frame.Origin,
|
||||
Rotation = building.Frame.Orientation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,746 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityEffectControllerTests
|
||||
{
|
||||
private const uint Guid = 0x70000001u;
|
||||
private const uint DirectDid = 0x33000011u;
|
||||
private const uint TypedDid = 0x33000022u;
|
||||
private const uint TableDid = 0x34000033u;
|
||||
private const uint RawType = 0x16u;
|
||||
|
||||
private sealed class NoopResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
|
||||
|
||||
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) =>
|
||||
Calls.Add((entityId, worldPosition, hook));
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
|
||||
private readonly Dictionary<uint, PhysicsScriptTable> _tables = new();
|
||||
private EntityEffectController? _controller;
|
||||
|
||||
public Fixture(
|
||||
Func<uint, uint, uint?>? childAtPart = null,
|
||||
Func<uint, uint?>? parentOfAttachedChild = null,
|
||||
Func<uint, PhysicsScriptTable?>? tableLoader = null,
|
||||
Action<uint>? ownerUnregistered = null,
|
||||
Action<uint, uint?>? ownerSoundTableChanged = null)
|
||||
{
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Runtime = new LiveEntityRuntime(
|
||||
Spatial,
|
||||
new NoopResources(),
|
||||
record => _controller?.OnLiveEntityUnregistered(record));
|
||||
Runner = new PhysicsScriptRunner(
|
||||
id => _scripts.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
|
||||
Router,
|
||||
randomUnit: () => 0.5,
|
||||
canAdvanceOwner: ownerId => _controller?.CanAdvanceOwner(ownerId) ?? true);
|
||||
Controller = new EntityEffectController(
|
||||
Runtime,
|
||||
Runner,
|
||||
new PhysicsScriptTableResolver(
|
||||
tableLoader
|
||||
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
|
||||
childAtPart,
|
||||
parentOfAttachedChild,
|
||||
ownerUnregistered,
|
||||
ownerSoundTableChanged);
|
||||
_controller = Controller;
|
||||
Router.Register(Sink);
|
||||
Router.Register(Controller);
|
||||
AddScript(DirectDid, 1u);
|
||||
AddScript(TypedDid, 2u);
|
||||
_tables.Add(TableDid, BuildTable(TableDid, RawType, (1f, TypedDid)));
|
||||
}
|
||||
|
||||
public GpuWorldState Spatial { get; } = new();
|
||||
public LiveEntityRuntime Runtime { get; }
|
||||
public RecordingSink Sink { get; } = new();
|
||||
public AnimationHookRouter Router { get; } = new();
|
||||
public PhysicsScriptRunner Runner { get; }
|
||||
public EntityEffectController Controller { get; }
|
||||
|
||||
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = startTime,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = emitterId },
|
||||
});
|
||||
_scripts[did] = script;
|
||||
}
|
||||
|
||||
public void AddScript(uint did, AnimationHook hook, double startTime = 0.0)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = startTime,
|
||||
Hook = hook,
|
||||
});
|
||||
_scripts[did] = script;
|
||||
}
|
||||
|
||||
public WorldEntity ReadyLive(
|
||||
uint guid = Guid,
|
||||
ushort generation = 1,
|
||||
EntityEffectProfile? profile = null)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, generation);
|
||||
Runtime.RegisterLiveEntity(spawn);
|
||||
Runtime.SetEffectProfile(guid, profile ?? LiveProfile());
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid))!;
|
||||
Assert.True(Controller.OnLiveEntityReady(guid));
|
||||
return entity;
|
||||
}
|
||||
|
||||
public static EntityEffectProfile LiveProfile(
|
||||
uint tableDid = TableDid,
|
||||
uint rawDefaultType = RawType,
|
||||
float defaultIntensity = 0.5f,
|
||||
uint? soundTableDid = null) =>
|
||||
EntityEffectProfile.CreateLive(
|
||||
new Setup(),
|
||||
default(PhysicsSpawnData) with
|
||||
{
|
||||
PhysicsScriptTableId = tableDid,
|
||||
SoundTableId = soundTableDid,
|
||||
DefaultScriptType = rawDefaultType,
|
||||
DefaultScriptIntensity = defaultIntensity,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreMaterializationDirectAndTypedPacketsReplayOnceInMixedOrder()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
|
||||
|
||||
Assert.Equal(2, fixture.Controller.PendingPacketCount);
|
||||
WorldEntity entity = fixture.ReadyLive();
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
|
||||
|
||||
// A duplicate readiness notification is idempotent and cannot replay.
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u, 2u], EmitterIds(fixture.Sink));
|
||||
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
|
||||
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ReadyLive();
|
||||
|
||||
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([2u, 1u], EmitterIds(fixture.Sink));
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CelllessOwnerRejectsNewPacketAndPausesExistingQueueUntilReentry()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
|
||||
fixture.ReadyLive();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
fixture.Runner.Tick(0.5);
|
||||
|
||||
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid));
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
fixture.Runner.Tick(2.0);
|
||||
Assert.Empty(fixture.Sink.Calls);
|
||||
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
||||
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u));
|
||||
fixture.Runner.Tick(2.0);
|
||||
Assert.Equal([1u], EmitterIds(fixture.Sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
|
||||
fixture.ReadyLive();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
fixture.Runner.Tick(2.0);
|
||||
Assert.Empty(fixture.Sink.Calls);
|
||||
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.ReportCollisions, 1, 3),
|
||||
out _));
|
||||
fixture.Runner.Tick(2.0);
|
||||
|
||||
Assert.Equal([1u, 1u], EmitterIds(fixture.Sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreCreatePacketWaitsForProjectionButPostCreateCelllessPacketIsDropped()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
|
||||
fixture.Runtime.RegisterLiveEntity(spawn);
|
||||
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
|
||||
WorldEntity entity = fixture.Runtime.MaterializeLiveEntity(
|
||||
Guid,
|
||||
0x02020001u,
|
||||
id => Entity(id, Guid))!;
|
||||
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
|
||||
Assert.Equal(1, fixture.Controller.PendingPacketCount);
|
||||
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
|
||||
Assert.Equal(1, fixture.Controller.PendingPacketCount);
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
fixture.Controller.RefreshLiveOwnerAnchors();
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
Assert.Equal([1u], EmitterIds(fixture.Sink));
|
||||
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedToPendingMovePausesActiveQueueUntilLandblockLoads()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
|
||||
fixture.ReadyLive();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
fixture.Runner.Tick(0.5);
|
||||
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x02020001u));
|
||||
fixture.Runner.Tick(2.0);
|
||||
Assert.Empty(fixture.Sink.Calls);
|
||||
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
||||
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
fixture.Runner.Tick(2.0);
|
||||
|
||||
Assert.Equal([1u], EmitterIds(fixture.Sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingInvalidTypedPacketDoesNotCorruptFollowingDirectPacket()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, float.NaN));
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
fixture.ReadyLive();
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u], EmitterIds(fixture.Sink));
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PacketNeverUsesServerGuidOrFallbackAnchorBeforeOwnerIsReady()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
fixture.Runner.Tick(10.0);
|
||||
|
||||
Assert.Empty(fixture.Sink.Calls);
|
||||
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
|
||||
Assert.Equal(1, fixture.Controller.PendingPacketCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptedDeleteClearsPendingGenerationBeforeGuidReuse()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
WorldSession.EntitySpawn first = Spawn(Guid, generation: 1);
|
||||
fixture.Runtime.RegisterLiveEntity(first);
|
||||
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
|
||||
fixture.ReadyLive(Guid, generation: 2);
|
||||
fixture.Runner.Tick(0.0);
|
||||
Assert.Empty(fixture.Sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleDeleteCannotClearCurrentGenerationQueue()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
WorldSession.EntitySpawn current = Spawn(Guid, generation: 2);
|
||||
fixture.Runtime.RegisterLiveEntity(current);
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
Assert.False(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(1, fixture.Controller.PendingPacketCount);
|
||||
|
||||
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
|
||||
fixture.Runtime.MaterializeLiveEntity(
|
||||
Guid,
|
||||
current.Position!.Value.LandblockId,
|
||||
id => Entity(id, Guid));
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
Assert.Equal([1u], EmitterIds(fixture.Sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownDeleteClearsQueueThatNeverReceivedCreateObject()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
fixture.Controller.ForgetUnknownOwner(Guid);
|
||||
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogicalTeardownStopsQueuedScriptsAndClearsReadyIdentity()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 60.0);
|
||||
fixture.ReadyLive();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
||||
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
|
||||
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
|
||||
Assert.Equal(0, fixture.Controller.ReadyOwnerCount);
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogicalTeardownReleasesOwnerScopedResourcesBeforeGuidReuse()
|
||||
{
|
||||
var removedOwners = new List<uint>();
|
||||
var soundTables = new DictionaryEntitySoundTable();
|
||||
var fixture = new Fixture(
|
||||
ownerUnregistered: ownerId =>
|
||||
{
|
||||
removedOwners.Add(ownerId);
|
||||
soundTables.Remove(ownerId);
|
||||
},
|
||||
ownerSoundTableChanged: (ownerId, soundTableDid) =>
|
||||
{
|
||||
soundTables.Remove(ownerId);
|
||||
if (soundTableDid is { } did)
|
||||
soundTables.Set(ownerId, did);
|
||||
});
|
||||
uint firstLocalId = fixture.ReadyLive(
|
||||
Guid,
|
||||
generation: 1,
|
||||
Fixture.LiveProfile(soundTableDid: 0x200000EEu)).Id;
|
||||
Assert.Equal(0x200000EEu, soundTables.GetSoundTableId(firstLocalId));
|
||||
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
uint replacementLocalId = fixture.ReadyLive(Guid, generation: 2).Id;
|
||||
|
||||
Assert.Equal([firstLocalId], removedOwners);
|
||||
Assert.Equal(0u, soundTables.GetSoundTableId(firstLocalId));
|
||||
Assert.Equal(0u, soundTables.GetSoundTableId(replacementLocalId));
|
||||
Assert.NotEqual(firstLocalId, replacementLocalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback()
|
||||
{
|
||||
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
|
||||
var fixture = new Fixture(
|
||||
ownerSoundTableChanged: (ownerId, soundTableDid) =>
|
||||
changed.Add((ownerId, soundTableDid)));
|
||||
var setup = new Setup { DefaultSoundTable = 0x200000DDu };
|
||||
EntityEffectProfile networkOverride = EntityEffectProfile.CreateLive(
|
||||
setup,
|
||||
default(PhysicsSpawnData) with { SoundTableId = 0x200000EEu });
|
||||
|
||||
WorldEntity first = fixture.ReadyLive(profile: networkOverride);
|
||||
|
||||
Assert.False(fixture.Runtime.TryGetAnimationRuntime(first.Id, out _));
|
||||
Assert.Equal([(first.Id, (uint?)0x200000EEu)], changed);
|
||||
|
||||
changed.Clear();
|
||||
EntityEffectProfile networkAbsent = EntityEffectProfile.CreateLive(
|
||||
setup,
|
||||
default);
|
||||
WorldEntity second = fixture.ReadyLive(
|
||||
guid: 0x70000002u,
|
||||
profile: networkAbsent);
|
||||
|
||||
Assert.Equal([(second.Id, (uint?)null)], changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenerationDescriptionRefreshReplacesAndClearsLiveSoundTable()
|
||||
{
|
||||
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
|
||||
var fixture = new Fixture(
|
||||
ownerSoundTableChanged: (ownerId, soundTableDid) =>
|
||||
changed.Add((ownerId, soundTableDid)));
|
||||
EntityEffectProfile profile = Fixture.LiveProfile(
|
||||
soundTableDid: 0x200000EEu);
|
||||
WorldEntity entity = fixture.ReadyLive(profile: profile);
|
||||
changed.Clear();
|
||||
|
||||
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
|
||||
{
|
||||
PhysicsScriptTableId = TableDid,
|
||||
SoundTableId = 0x200000EFu,
|
||||
});
|
||||
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
|
||||
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
|
||||
{
|
||||
PhysicsScriptTableId = TableDid,
|
||||
SoundTableId = 0u,
|
||||
});
|
||||
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(entity.Id, (uint?)0x200000EFu),
|
||||
(entity.Id, (uint?)null),
|
||||
],
|
||||
changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPesAndDefaultHooksEnqueueThroughOwningProfiles()
|
||||
{
|
||||
const uint nestedDid = 0x33000044u;
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(nestedDid, 4u);
|
||||
WorldEntity entity = fixture.ReadyLive();
|
||||
|
||||
fixture.Controller.OnHook(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
new DefaultScriptHook());
|
||||
fixture.Controller.OnHook(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
new CallPESHook { PES = nestedDid, Pause = 0f });
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([2u, 4u], EmitterIds(fixture.Sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunnerRouterControllerPathExecutesNestedCallPes()
|
||||
{
|
||||
const uint outerDid = 0x33000043u;
|
||||
const uint nestedDid = 0x33000044u;
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(outerDid, new CallPESHook { PES = nestedDid, Pause = 0f });
|
||||
fixture.AddScript(nestedDid, 4u);
|
||||
fixture.ReadyLive();
|
||||
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, outerDid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Collection(
|
||||
fixture.Sink.Calls,
|
||||
call => Assert.IsType<CallPESHook>(call.Hook),
|
||||
call => Assert.Equal(4u, Assert.IsType<CreateParticleHook>(call.Hook).EmitterInfoId.DataId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultScriptPartResolvesAttachedChildAndUsesChildProfile()
|
||||
{
|
||||
uint parentLocalId = 0;
|
||||
uint childLocalId = 0;
|
||||
var fixture = new Fixture((parent, part) =>
|
||||
parent == parentLocalId && part == 7u ? childLocalId : null);
|
||||
parentLocalId = fixture.ReadyLive(Guid, generation: 1).Id;
|
||||
childLocalId = fixture.ReadyLive(0x70000002u, generation: 1).Id;
|
||||
|
||||
fixture.Controller.OnHook(
|
||||
parentLocalId,
|
||||
Vector3.Zero,
|
||||
new DefaultScriptPartHook { PartIndex = 7u });
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
var call = Assert.Single(fixture.Sink.Calls);
|
||||
Assert.Equal(childLocalId, call.EntityId);
|
||||
Assert.Equal(2u, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachedChildAdvancesThroughEligibleParentWhileCellless()
|
||||
{
|
||||
uint parentLocalId = 0;
|
||||
uint childLocalId = 0;
|
||||
var fixture = new Fixture(
|
||||
parentOfAttachedChild: child => child == childLocalId ? parentLocalId : null);
|
||||
parentLocalId = fixture.ReadyLive(Guid).Id;
|
||||
childLocalId = fixture.ReadyLive(0x70000002u).Id;
|
||||
fixture.Runtime.WithdrawLiveEntityProjection(0x70000002u);
|
||||
|
||||
Assert.True(fixture.Controller.PlayDefault(childLocalId));
|
||||
fixture.Runner.Tick(0.0);
|
||||
Assert.Equal(childLocalId, Assert.Single(fixture.Sink.Calls).EntityId);
|
||||
|
||||
fixture.Runtime.WithdrawLiveEntityProjection(Guid);
|
||||
Assert.False(fixture.Controller.PlayDefault(childLocalId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDirectScriptReportsOwnerAndDid()
|
||||
{
|
||||
const uint missingDid = 0x3300DEADu;
|
||||
var fixture = new Fixture();
|
||||
WorldEntity entity = fixture.ReadyLive();
|
||||
var diagnostics = new List<string>();
|
||||
fixture.Controller.DiagnosticSink = diagnostics.Add;
|
||||
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, missingDid));
|
||||
|
||||
string message = Assert.Single(diagnostics);
|
||||
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
|
||||
Assert.Contains($"0x{missingDid:X8}", message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypedTableLoadFailureReportsOwnerTableAndException()
|
||||
{
|
||||
var fixture = new Fixture(
|
||||
tableLoader: _ => throw new InvalidDataException("fixture corrupt table"));
|
||||
WorldEntity entity = fixture.ReadyLive();
|
||||
var diagnostics = new List<string>();
|
||||
fixture.Controller.DiagnosticSink = diagnostics.Add;
|
||||
|
||||
fixture.Controller.HandleTyped(
|
||||
new PlayPhysicsScriptType(Guid, RawType, 0.5f));
|
||||
|
||||
string message = Assert.Single(diagnostics);
|
||||
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
|
||||
Assert.Contains($"0x{TableDid:X8}", message, StringComparison.Ordinal);
|
||||
Assert.Contains(nameof(InvalidDataException), message, StringComparison.Ordinal);
|
||||
Assert.Contains("fixture corrupt table", message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticOwnerRetainsSetupTableAndIsRemovedSymmetrically()
|
||||
{
|
||||
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
|
||||
var fixture = new Fixture(
|
||||
ownerSoundTableChanged: (ownerId, soundTableDid) =>
|
||||
changed.Add((ownerId, soundTableDid)));
|
||||
var setup = new Setup
|
||||
{
|
||||
DefaultScriptTable = TableDid,
|
||||
DefaultSoundTable = 0x200000DDu,
|
||||
};
|
||||
WorldEntity entity = Entity(55u, 0u);
|
||||
fixture.Controller.OnDatStaticEntityReady(
|
||||
entity.Id,
|
||||
entity,
|
||||
EntityEffectProfile.CreateDatStatic(setup));
|
||||
|
||||
Assert.Equal([(entity.Id, (uint?)0x200000DDu)], changed);
|
||||
Assert.True(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
|
||||
fixture.Runner.Tick(0.0);
|
||||
Assert.Equal([2u], EmitterIds(fixture.Sink));
|
||||
|
||||
fixture.Controller.OnDatStaticEntityRemoved(entity.Id);
|
||||
Assert.False(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredSyntheticOwnerAdvancesThroughGlobalRunnerEligibility()
|
||||
{
|
||||
const uint skyOwnerId = 0xF0000042u;
|
||||
var fixture = new Fixture();
|
||||
fixture.Controller.RegisterSyntheticOwner(skyOwnerId);
|
||||
|
||||
Assert.True(fixture.Runner.PlayDirect(skyOwnerId, DirectDid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(skyOwnerId, Assert.Single(fixture.Sink.Calls).EntityId);
|
||||
fixture.Controller.UnregisterSyntheticOwner(skyOwnerId);
|
||||
Assert.False(fixture.Controller.CanAdvanceOwner(skyOwnerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
|
||||
WorldEntity entity = fixture.ReadyLive();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
entity.SetPosition(new Vector3(10, 20, 30));
|
||||
|
||||
fixture.Controller.RefreshLiveOwnerAnchors();
|
||||
fixture.Runner.Tick(1.0);
|
||||
|
||||
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearNetworkStatePreservesDatStaticProfiles()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ReadyLive();
|
||||
WorldEntity staticEntity = Entity(55u, 0u);
|
||||
fixture.Controller.OnDatStaticEntityReady(
|
||||
staticEntity.Id,
|
||||
staticEntity,
|
||||
EntityEffectProfile.CreateDatStatic(
|
||||
new Setup { DefaultScriptTable = TableDid }));
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
|
||||
fixture.Controller.ClearNetworkState();
|
||||
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
|
||||
Assert.True(fixture.Controller.PlayTyped(staticEntity.Id, RawType, 0.5f));
|
||||
}
|
||||
|
||||
private static PhysicsScriptTable BuildTable(
|
||||
uint tableDid,
|
||||
uint rawType,
|
||||
params (float Mod, uint ScriptDid)[] entries)
|
||||
{
|
||||
var data = new PhysicsScriptTableData();
|
||||
foreach ((float mod, uint scriptDid) in entries)
|
||||
{
|
||||
data.Scripts.Add(new ScriptAndModData
|
||||
{
|
||||
Mod = mod,
|
||||
ScriptId = scriptDid,
|
||||
});
|
||||
}
|
||||
|
||||
var table = new PhysicsScriptTable { Id = tableDid };
|
||||
table.ScriptTable.Add(unchecked((PlayScript)rawType), data);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id, uint guid) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(1, 2, 3),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, ushort generation)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
1, 1, 1, 1, 0, 1, 0, 1, generation);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: TableDid,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: RawType,
|
||||
DefaultScriptIntensity: 0.5f,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: generation,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static uint[] EmitterIds(RecordingSink sink) =>
|
||||
sink.Calls
|
||||
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ public sealed class EntityEffectProfileTests
|
|||
|
||||
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
|
||||
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
|
||||
Assert.Equal(0x200000DDu, profile.CurrentSoundTableDid);
|
||||
Assert.False(profile.HasNetworkDescription);
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ public sealed class EntityEffectProfileTests
|
|||
|
||||
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
|
||||
Assert.Null(profile.CurrentPhysicsScriptTableDid);
|
||||
Assert.Null(profile.CurrentSoundTableDid);
|
||||
Assert.Equal(0u, profile.RawDefaultScriptType);
|
||||
Assert.Equal(0f, profile.DefaultScriptIntensity);
|
||||
Assert.True(profile.HasNetworkDescription);
|
||||
|
|
@ -38,6 +40,7 @@ public sealed class EntityEffectProfileTests
|
|||
PhysicsSpawnData physics = default(PhysicsSpawnData) with
|
||||
{
|
||||
PhysicsScriptTableId = 0x340000CCu,
|
||||
SoundTableId = 0x200000EEu,
|
||||
DefaultScriptType = 0xA5A5A5A5u,
|
||||
DefaultScriptIntensity = 0.75f,
|
||||
};
|
||||
|
|
@ -47,6 +50,7 @@ public sealed class EntityEffectProfileTests
|
|||
physics);
|
||||
|
||||
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
|
||||
Assert.Equal(0x200000EEu, profile.CurrentSoundTableDid);
|
||||
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
|
||||
Assert.Equal(0.75f, profile.DefaultScriptIntensity);
|
||||
}
|
||||
|
|
@ -66,11 +70,13 @@ public sealed class EntityEffectProfileTests
|
|||
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
|
||||
{
|
||||
PhysicsScriptTableId = 0u,
|
||||
SoundTableId = 0u,
|
||||
DefaultScriptType = 0u,
|
||||
DefaultScriptIntensity = 0f,
|
||||
});
|
||||
|
||||
Assert.Null(profile.CurrentPhysicsScriptTableDid);
|
||||
Assert.Null(profile.CurrentSoundTableDid);
|
||||
Assert.Equal(0u, profile.RawDefaultScriptType);
|
||||
Assert.Equal(0f, profile.DefaultScriptIntensity);
|
||||
}
|
||||
|
|
@ -79,5 +85,6 @@ public sealed class EntityEffectProfileTests
|
|||
{
|
||||
DefaultScript = 0x330000AAu,
|
||||
DefaultScriptTable = 0x340000BBu,
|
||||
DefaultSoundTable = 0x200000DDu,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
|
|
@ -8,7 +9,7 @@ using DatReaderWriter.Types;
|
|||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Vfx;
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityScriptActivatorTests
|
||||
{
|
||||
|
|
@ -209,7 +210,7 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.True(system.ActiveEmitterCount > 0,
|
||||
"Setup precondition failed: emitter should be alive after the hook fires.");
|
||||
|
||||
activator.OnRemove(0xCAFEu);
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
|
||||
|
|
@ -218,7 +219,7 @@ public sealed class EntityScriptActivatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_KeysByEntityId_WhenServerGuidZero()
|
||||
public void OnCreate_UsesCanonicalStaticIdentity_WhenServerGuidZero()
|
||||
{
|
||||
// C.1.5b: dat-hydrated EnvCell statics + exterior stabs have
|
||||
// ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range.
|
||||
|
|
@ -241,10 +242,49 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
|
||||
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
|
||||
|
||||
activator.OnCreate(first);
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
|
||||
() => activator.OnCreate(second));
|
||||
|
||||
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(1, p.Runner.ActiveOwnerCount);
|
||||
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1_000_123u,
|
||||
ServerGuid = 0x70000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
|
||||
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_PassesPartTransformsToSink()
|
||||
{
|
||||
|
|
@ -288,7 +328,7 @@ public sealed class EntityScriptActivatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsByGivenKey_ForDatHydratedEntity()
|
||||
public void OnRemove_StopsReservedOwner_ForDatHydratedEntity()
|
||||
{
|
||||
// C.1.5b: caller passes the entity.Id as the key for dat-hydrated
|
||||
// entities (not ServerGuid). OnRemove must clean up correctly.
|
||||
|
|
@ -318,48 +358,21 @@ public sealed class EntityScriptActivatorTests
|
|||
runner.Tick(0.001f);
|
||||
Assert.True(system.ActiveEmitterCount > 0);
|
||||
|
||||
activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias()
|
||||
private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
|
||||
{
|
||||
const uint serverGuid = 0x7000CAFEu;
|
||||
const uint localId = 0x00100001u;
|
||||
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
|
||||
var p = BuildPipeline((0xAAu, script));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
Id = id,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
// This is the current AD-32 pre-materialization F754 path: no local ID
|
||||
// exists yet, so the direct PES is temporarily owned by server GUID.
|
||||
Assert.True(activator.PlayLegacyPending(serverGuid, 0xAAu, Vector3.Zero));
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(1, activator.LegacyPendingOwnerCount);
|
||||
|
||||
activator.OnRemoveLegacyOwner(serverGuid, localId);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearLegacyPendingOwners_StopsF754AliasesWithoutLiveRecords()
|
||||
{
|
||||
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
|
||||
var p = BuildPipeline((0xAAu, script));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
Assert.True(activator.PlayLegacyPending(0x70000001u, 0xAAu, Vector3.Zero));
|
||||
Assert.True(activator.PlayLegacyPending(0x70000002u, 0xAAu, Vector3.Zero));
|
||||
Assert.Equal(2, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(2, activator.LegacyPendingOwnerCount);
|
||||
|
||||
activator.ClearLegacyPendingOwners();
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ using DatReaderWriter.Types;
|
|||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
|
||||
|
|
@ -94,7 +94,7 @@ public sealed class GpuWorldStateActivatorTests
|
|||
// Tick fires the CreateParticleHook into RecordingSink.
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
|
|
@ -460,6 +460,46 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootRuntimePausesWhilePendingOrFrozenAndResumesInVisibleCell()
|
||||
{
|
||||
const uint guid = 0x70000027u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)PhysicsStateFlags.ReportCollisions,
|
||||
1,
|
||||
3),
|
||||
out _));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
|
||||
{
|
||||
|
|
@ -701,109 +741,6 @@ public sealed class LiveEntityRuntimeTests
|
|||
firstLocalEntityId: uint.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_StopsScriptsOwnedByCanonicalLocalId_NotServerGuid()
|
||||
{
|
||||
const uint serverGuid = 0x70000013u;
|
||||
const uint scriptId = 0x33000001u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 60d,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
|
||||
});
|
||||
var particleSystem = new ParticleSystem(new EmitterDescRegistry());
|
||||
var particleSink = new ParticleHookSink(particleSystem);
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => id == scriptId ? script : null,
|
||||
particleSink);
|
||||
var activator = new EntityScriptActivator(
|
||||
runner,
|
||||
particleSink,
|
||||
_ => new ScriptActivationInfo(scriptId, Array.Empty<System.Numerics.Matrix4x4>()));
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(
|
||||
activator.OnCreate,
|
||||
entity => activator.OnRemove(entity.Id)),
|
||||
record => activator.OnRemoveLegacyOwner(
|
||||
record.ServerGuid,
|
||||
record.LocalEntityId ?? 0u));
|
||||
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 7, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
|
||||
// Current AD-32 behavior before Step 4's pending FIFO: an F754 that
|
||||
// precedes materialization temporarily starts under server GUID.
|
||||
Assert.True(activator.PlayLegacyPending(
|
||||
serverGuid,
|
||||
scriptId,
|
||||
System.Numerics.Vector3.Zero));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
serverGuid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, serverGuid))!;
|
||||
|
||||
Assert.NotEqual(serverGuid, entity.Id);
|
||||
Assert.Equal(2, runner.ActiveScriptCount);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false));
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleDelete_DoesNotStopCurrentGenerationPreMaterializationF754Alias()
|
||||
{
|
||||
const uint serverGuid = 0x70000027u;
|
||||
const uint scriptId = 0x33000001u;
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 60d,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
|
||||
});
|
||||
var particleSink = new ParticleHookSink(
|
||||
new ParticleSystem(new EmitterDescRegistry()));
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => id == scriptId ? script : null,
|
||||
particleSink);
|
||||
var activator = new EntityScriptActivator(
|
||||
runner,
|
||||
particleSink,
|
||||
_ => null);
|
||||
var runtime = new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources(),
|
||||
record => activator.OnRemoveLegacyOwner(
|
||||
record.ServerGuid,
|
||||
record.LocalEntityId ?? 0u));
|
||||
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 10, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
Assert.True(activator.PlayLegacyPending(
|
||||
serverGuid,
|
||||
scriptId,
|
||||
System.Numerics.Vector3.Zero));
|
||||
|
||||
Assert.False(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, 9),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, activator.LegacyPendingOwnerCount);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, 10),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ public sealed class RetailDatLoaderTests
|
|||
private sealed class RawDatabase : IDatDatabase
|
||||
{
|
||||
private readonly Dictionary<uint, byte[]> _entries = new();
|
||||
private int _activeReads;
|
||||
private int _maxConcurrentReads;
|
||||
private int _totalReads;
|
||||
|
||||
public DatDatabase Db => null!;
|
||||
public int Iteration => 0;
|
||||
public int ReadDelayMilliseconds { get; set; }
|
||||
public int MaxConcurrentReads => Volatile.Read(ref _maxConcurrentReads);
|
||||
public int TotalReads => Volatile.Read(ref _totalReads);
|
||||
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
|
|
@ -28,8 +34,28 @@ public sealed class RetailDatLoaderTests
|
|||
value = default;
|
||||
return false;
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
|
||||
_entries.TryGetValue(fileId, out value);
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
|
||||
{
|
||||
Interlocked.Increment(ref _totalReads);
|
||||
int active = Interlocked.Increment(ref _activeReads);
|
||||
int observed;
|
||||
do
|
||||
{
|
||||
observed = Volatile.Read(ref _maxConcurrentReads);
|
||||
}
|
||||
while (active > observed
|
||||
&& Interlocked.CompareExchange(ref _maxConcurrentReads, active, observed) != observed);
|
||||
try
|
||||
{
|
||||
if (ReadDelayMilliseconds > 0)
|
||||
Thread.Sleep(ReadDelayMilliseconds);
|
||||
return _entries.TryGetValue(fileId, out value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _activeReads);
|
||||
}
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
if (!_entries.TryGetValue(fileId, out byte[]? value))
|
||||
|
|
@ -176,6 +202,47 @@ public sealed class RetailDatLoaderTests
|
|||
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads()
|
||||
{
|
||||
const uint firstDid = 0x33010010u;
|
||||
const uint secondDid = 0x33010011u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
byte[] first = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
byte[] second = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(first, firstDid);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(second, secondDid);
|
||||
portal.Add(firstDid, first);
|
||||
portal.Add(secondDid, second);
|
||||
var loader = new RetailPhysicsScriptLoader(portal);
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => loader.LoadPhysicsScript(firstDid)),
|
||||
Task.Run(() => loader.LoadPhysicsScript(secondDid)));
|
||||
|
||||
Assert.Equal(2, portal.MaxConcurrentReads);
|
||||
Assert.Equal(2, portal.TotalReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid()
|
||||
{
|
||||
const uint scriptDid = 0x33010012u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(bytes, scriptDid);
|
||||
portal.Add(scriptDid, bytes);
|
||||
var loader = new RetailPhysicsScriptLoader(portal);
|
||||
|
||||
DatPhysicsScript?[] loaded = await Task.WhenAll(
|
||||
Task.Run(() => loader.LoadPhysicsScript(scriptDid)),
|
||||
Task.Run(() => loader.LoadPhysicsScript(scriptDid)));
|
||||
|
||||
Assert.All(loaded, Assert.NotNull);
|
||||
Assert.Same(loaded[0], loaded[1]);
|
||||
Assert.Equal(1, portal.TotalReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectTrailingOrTruncatedEntries()
|
||||
{
|
||||
|
|
@ -192,6 +259,17 @@ public sealed class RetailDatLoaderTests
|
|||
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsScriptParserRejectsNonFiniteHookTime()
|
||||
{
|
||||
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(
|
||||
script.AsSpan(8, 8),
|
||||
BitConverter.DoubleToInt64Bits(double.NaN));
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => RetailPhysicsScriptLoader.Parse(script));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,234 +1,517 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Vfx;
|
||||
|
||||
public sealed class PhysicsScriptRunnerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Recording sink so tests can assert each hook dispatch.
|
||||
/// </summary>
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
|
||||
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
|
||||
=> Calls.Add((entityId, worldPos, hook));
|
||||
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
|
||||
|
||||
public Action<uint, Vector3, AnimationHook>? Callback { get; init; }
|
||||
|
||||
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
|
||||
{
|
||||
Calls.Add((entityId, worldPosition, hook));
|
||||
Callback?.Invoke(entityId, worldPosition, hook);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
|
||||
private static DatPhysicsScript BuildScript(params (double Time, AnimationHook Hook)[] items)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
foreach (var (t, h) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
|
||||
foreach ((double time, AnimationHook hook) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = time, Hook = hook });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static CreateParticleHook CreateHook(uint emitterInfoId)
|
||||
=> new CreateParticleHook { EmitterInfoId = emitterInfoId };
|
||||
private static CreateParticleHook CreateHook(uint emitterInfoId) =>
|
||||
new() { EmitterInfoId = emitterInfoId };
|
||||
|
||||
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts)
|
||||
private static PhysicsScriptRunner MakeRunner(
|
||||
IAnimationHookSink sink,
|
||||
Func<double>? clock = null,
|
||||
Func<double>? randomUnit = null,
|
||||
Func<uint, bool>? canAdvanceOwner = null,
|
||||
params (uint Id, DatPhysicsScript Script)[] scripts)
|
||||
{
|
||||
// Build an in-memory resolver from the script table — no DatCollection needed.
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var table = scripts.ToDictionary(entry => entry.Id, entry => entry.Script);
|
||||
return new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
sink);
|
||||
id => table.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
|
||||
sink,
|
||||
clock,
|
||||
randomUnit,
|
||||
canAdvanceOwner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_UnknownScript_ReturnsFalse()
|
||||
public void PlayDirect_MissingOrZeroInputReturnsFalseWithoutQueueCorruption()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink); // no scripts registered
|
||||
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
var runner = MakeRunner(sink);
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, 0u));
|
||||
Assert.False(runner.PlayDirect(0u, 0x33000001u));
|
||||
Assert.False(runner.PlayDirect(1u, 0x33000001u));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
Assert.Empty(sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_ZeroScriptId_IgnoredSilently()
|
||||
public void HooksFireInOrderAtAbsoluteScheduledTimes()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(100)),
|
||||
(0.5, CreateHook(101)),
|
||||
(1.0, CreateHook(102)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink);
|
||||
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.SetOwnerAnchor(7u, new Vector3(1, 2, 3));
|
||||
|
||||
Assert.True(runner.PlayDirect(7u, 0x330000AAu));
|
||||
runner.Tick(0.25);
|
||||
runner.Tick(0.60);
|
||||
runner.Tick(1.50);
|
||||
|
||||
Assert.Equal([100u, 101u, 102u],
|
||||
sink.Calls.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId));
|
||||
Assert.All(sink.Calls, call => Assert.Equal(new Vector3(1, 2, 3), call.Position));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HooksFire_InOrder_AtScheduledTimes()
|
||||
public void DuplicatePlaysAppendSeriallyToOneOwner()
|
||||
{
|
||||
var script = BuildScript(
|
||||
(0.0, CreateHook(100)),
|
||||
(0.5, CreateHook(101)),
|
||||
(1.0, CreateHook(102)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
|
||||
|
||||
runner.Tick(0.25f);
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
|
||||
runner.Tick(0.35f); // total 0.6
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
|
||||
|
||||
runner.Tick(0.9f); // total 1.5
|
||||
Assert.Equal(3, sink.Calls.Count);
|
||||
Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId);
|
||||
Assert.Equal(0, runner.ActiveScriptCount); // fully consumed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityIdAndAnchor_ArePassedThrough()
|
||||
{
|
||||
var script = BuildScript((0.0, CreateHook(1)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
|
||||
var anchor = new Vector3(123, 45, 67);
|
||||
runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor);
|
||||
runner.Tick(0.1f);
|
||||
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(0xCAFEu, sink.Calls[0].EntityId);
|
||||
Assert.Equal(anchor, sink.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_SameScriptSameEntity_Replaces_DoesNotStack()
|
||||
{
|
||||
var script = BuildScript(
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.1f);
|
||||
Assert.Single(sink.Calls);
|
||||
|
||||
// Re-play — the old instance should be replaced, not stacked.
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
runner.Tick(0.1f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
// Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1.
|
||||
Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_DifferentEntities_BothActiveConcurrently()
|
||||
{
|
||||
var script = BuildScript((0.0, CreateHook(42)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0));
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
Assert.Equal(2, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, runner.ActiveOwnerCount);
|
||||
|
||||
runner.Tick(0.1f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
Assert.Contains(sink.Calls, c => c.EntityId == 1u);
|
||||
Assert.Contains(sink.Calls, c => c.EntityId == 2u);
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
|
||||
// At exactly one second the first tail and the second head are both due.
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
|
||||
|
||||
runner.Tick(2.0);
|
||||
Assert.Equal([1u, 2u, 1u, 2u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers()
|
||||
public void DifferentOwnersProgressIndependently()
|
||||
{
|
||||
var script = BuildScript(
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.1f); // both fire hook 0
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.Tick(0.5);
|
||||
runner.PlayDirect(2u, 0x330000AAu);
|
||||
runner.Tick(1.0);
|
||||
|
||||
runner.StopAllForEntity(1);
|
||||
Assert.Equal([(1u, 1u), (1u, 2u), (2u, 1u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
runner.Tick(2.0f); // only entity 2's script should fire hook 1
|
||||
Assert.Equal(3, sink.Calls.Count);
|
||||
Assert.Equal(2u, sink.Calls[^1].EntityId);
|
||||
Assert.Equal(1, runner.ActiveOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_NestedScript_SpawnsOnSameEntity()
|
||||
public void CatchUpTickDrainsEveryDueHookAndQueuedScript()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(0.25, CreateHook(2)),
|
||||
(0.5, CreateHook(3)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
// First tick fires the CallPES hook. Inner script gets queued to
|
||||
// _active but does NOT fire this tick (we iterate _active
|
||||
// backwards, and the inner is appended AFTER the current index) —
|
||||
// matches retail's linked-list insertion semantics. Inner fires
|
||||
// on the NEXT tick instead.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit
|
||||
Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done
|
||||
runner.Tick(5.0);
|
||||
|
||||
// Second tick — inner's hook at t=0 fires now.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
Assert.Equal(0x7u, sink.Calls[0].EntityId);
|
||||
Assert.Equal([1u, 2u, 3u, 1u, 2u, 3u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_WithPause_DelaysSubScript()
|
||||
public void HookMayAppendToCurrentOwnerWithoutInvalidatingCatchUp()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
DatPhysicsScript outer = BuildScript((0.0, CreateHook(1)));
|
||||
DatPhysicsScript nested = BuildScript((0.0, CreateHook(2)));
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.PlayDirect(owner, 0x330000BBu);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts: [(0x330000AAu, outer), (0x330000BBu, nested)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.0);
|
||||
|
||||
// CallPES fires immediately, but inner script's hook is gated by Pause.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s
|
||||
|
||||
runner.Tick(0.5f); // total 0.6 > 0.5 pause
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance()
|
||||
public void HookMayDeleteItsOwnerWithoutDispatchingDetachedTail()
|
||||
{
|
||||
var script = BuildScript(
|
||||
(0.0, new CallPESHook { PES = 0xAA, Pause = 30f }),
|
||||
(0.0, CreateHook(123)));
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(0.0, CreateHook(2)));
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.StopAllForEntity(owner);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleCallPesUsesInjectedUniformDelayAndNearZeroAppendsImmediately()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Tick(0.1f);
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
|
||||
Assert.Equal(1, runner.ScheduledCallPesCount);
|
||||
runner.Tick(0.999);
|
||||
Assert.Empty(sink.Calls);
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([9u], EmitterIds(sink));
|
||||
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
Assert.True(runner.ScheduleCallPes(
|
||||
1u,
|
||||
0x330000AAu,
|
||||
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
|
||||
runner.Tick(29.8f);
|
||||
Assert.Single(sink.Calls);
|
||||
|
||||
runner.Tick(0.3f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([9u, 9u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedAnimationPhaseRunsImmediateScriptButDefersTimedCallOnePass()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.0,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.PublishTime(1.0);
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 0.5f));
|
||||
Assert.True(runner.ScheduleCallPes(
|
||||
2u,
|
||||
0x330000AAu,
|
||||
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([(2u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
|
||||
runner.PublishTime(1.1);
|
||||
runner.Tick(1.1);
|
||||
|
||||
Assert.Equal([(2u, 9u), (1u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePhasePlayUsesPublishedCurrentClockForPositiveHookOffset()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.1, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.PublishTime(1.0);
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
runner.Tick(1.0);
|
||||
Assert.Empty(sink.Calls);
|
||||
|
||||
runner.PublishTime(1.1);
|
||||
runner.Tick(1.1);
|
||||
Assert.Equal([9u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IneligibleOwnerRetainsScriptsAndTimedCallsUntilReentry()
|
||||
{
|
||||
bool eligible = true;
|
||||
DatPhysicsScript script = BuildScript((1.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
canAdvanceOwner: _ => eligible,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.ScheduleCallPes(2u, 0x330000AAu, 2f);
|
||||
|
||||
eligible = false;
|
||||
runner.Tick(2.0);
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, runner.ScheduledCallPesCount);
|
||||
|
||||
eligible = true;
|
||||
runner.Tick(2.0);
|
||||
Assert.Equal([(1u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
runner.Tick(3.0);
|
||||
Assert.Equal([(1u, 9u), (2u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroTimeRecursiveCallChainIsRejectedWithoutHanging()
|
||||
{
|
||||
const uint firstDid = 0x330000AAu;
|
||||
const uint secondDid = 0x330000BBu;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
|
||||
? secondDid
|
||||
: firstDid;
|
||||
runner!.PlayDirect(owner, target);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(firstDid, BuildScript((0.0, CreateHook(1)))),
|
||||
(secondDid, BuildScript((0.0, CreateHook(2)))),
|
||||
]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
runner.PlayDirect(1u, firstDid);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
Assert.Single(diagnostics, message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveDurationRecursiveChainDrainsCatchUpWithoutFalseCycleRejection()
|
||||
{
|
||||
const uint firstDid = 0x33000428u;
|
||||
const uint secondDid = 0x33000429u;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
|
||||
? secondDid
|
||||
: firstDid;
|
||||
runner!.PlayDirect(owner, target);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(firstDid, BuildScript((2.8, CreateHook(1)))),
|
||||
(secondDid, BuildScript((2.8, CreateHook(2)))),
|
||||
]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
runner.PlayDirect(1u, firstDid);
|
||||
|
||||
runner.Tick(8.4);
|
||||
|
||||
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
|
||||
Assert.DoesNotContain(
|
||||
diagnostics,
|
||||
message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossOwnerReentrantPlayWaitsForNextGlobalScriptPass()
|
||||
{
|
||||
const uint parentDid = 0x330000AAu;
|
||||
const uint childCurrentDid = 0x330000BBu;
|
||||
const uint childDefaultDid = 0x330000CCu;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (_, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.PlayDirect(2u, childDefaultDid);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(parentDid, BuildScript((0.0, CreateHook(1)))),
|
||||
(childCurrentDid, BuildScript((0.0, CreateHook(2)))),
|
||||
(childDefaultDid, BuildScript((0.0, CreateHook(3)))),
|
||||
]);
|
||||
runner.PlayDirect(1u, parentDid);
|
||||
runner.PlayDirect(2u, childCurrentDid);
|
||||
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u, 2u, 3u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFiniteTimelineIsRejectedBeforeItCanBypassRecursionGuard()
|
||||
{
|
||||
const uint scriptDid = 0x330000AAu;
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
scripts: [(scriptDid, BuildScript((double.NaN, CreateHook(1))))]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, scriptDid));
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Contains(diagnostics, message => message.Contains("non-finite", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleCallPesRejectsNonFiniteInputsAndStopClearsDelayedCalls()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.NaN));
|
||||
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.PositiveInfinity));
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
|
||||
runner.StopAllForEntity(1u);
|
||||
runner.Tick(10.0);
|
||||
|
||||
Assert.Equal(0, runner.ScheduledCallPesCount);
|
||||
Assert.Empty(sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentAnchorIsReadWhenHookFires()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((1.0, CreateHook(1)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.SetOwnerAnchor(1u, new Vector3(1, 0, 0));
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.SetOwnerAnchor(1u, new Vector3(7, 8, 9));
|
||||
|
||||
runner.Tick(1.0);
|
||||
|
||||
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(sink.Calls).Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryHookFansOutThroughCompleteRouter()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(1)));
|
||||
var first = new RecordingSink();
|
||||
var second = new RecordingSink();
|
||||
var router = new AnimationHookRouter();
|
||||
router.Register(first);
|
||||
router.Register(second);
|
||||
var runner = MakeRunner(router, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Single(first.Calls);
|
||||
Assert.Single(second.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoaderFailureIsCachedAndDoesNotCorruptOtherOwners()
|
||||
{
|
||||
DatPhysicsScript good = BuildScript((0.0, CreateHook(1)));
|
||||
int failingLoads = 0;
|
||||
var sink = new RecordingSink();
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id =>
|
||||
{
|
||||
if (id == 0x330000AAu)
|
||||
return good;
|
||||
failingLoads++;
|
||||
throw new InvalidDataException("fixture DAT failure");
|
||||
},
|
||||
sink);
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
|
||||
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
|
||||
Assert.True(runner.PlayDirect(2u, 0x330000AAu));
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(1, failingLoads);
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickRejectsNonFiniteOrBackwardsTime()
|
||||
{
|
||||
var runner = MakeRunner(new RecordingSink());
|
||||
runner.Tick(1.0);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(0.5));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.NaN));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.PositiveInfinity));
|
||||
}
|
||||
|
||||
private static uint[] EmitterIds(RecordingSink sink) =>
|
||||
sink.Calls
|
||||
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ public sealed class PhysicsScriptTableResolverTests
|
|||
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LoaderFailureReturnsNoPlayAndPreservesDiagnosticException()
|
||||
{
|
||||
var resolver = new PhysicsScriptTableResolver(
|
||||
_ => throw new InvalidDataException("fixture DAT failure"));
|
||||
|
||||
Assert.Null(resolver.Resolve(TableDid, RawType, 0f, out Exception? failure));
|
||||
var invalid = Assert.IsType<InvalidDataException>(failure);
|
||||
Assert.Equal("fixture DAT failure", invalid.Message);
|
||||
}
|
||||
|
||||
private static PhysicsScriptTable BuildTable(
|
||||
params (float Mod, uint ScriptDid)[] entries)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -87,4 +87,20 @@ public class InteriorEntityIdAllocatorTests
|
|||
// that accidentally narrows it again gets caught here first.
|
||||
Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
|
||||
{
|
||||
uint counter = InteriorEntityIdAllocator.MaxCounter;
|
||||
|
||||
uint last = InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter);
|
||||
|
||||
Assert.Equal(
|
||||
InteriorEntityIdAllocator.Base(0xA9u, 0xB4u)
|
||||
+ InteriorEntityIdAllocator.MaxCounter,
|
||||
last);
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||
() => InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter));
|
||||
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,19 @@ public class LandblockLoaderTests
|
|||
Assert.Equal(1u, entities[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias()
|
||||
{
|
||||
var info = new LandBlockInfo();
|
||||
for (uint i = 0; i < 256u; i++)
|
||||
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
|
||||
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||
() => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u));
|
||||
|
||||
Assert.Contains("255-entry", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue