diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 70036534..b14fb709 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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: diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e67edbfd..3aadfa27 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 45cb898e..cc17126c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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`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. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9802a3c8..be4ca8ef 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5aba1568..ec308efb 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.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 diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index a8649e4e..2baa9933 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -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 _acceptParent; private ParentAttachmentState Relations => _liveEntities.ParentAttachments; + + /// Raised after the attached projection is fully registered. + public event Action? EntityReady; private readonly Dictionary _attachedByChild = new(); public IEnumerable 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); + } + + /// + /// Resolves retail's parent Setup part index to the currently attached + /// child local ID for DefaultScriptPartHook. + /// + 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; + } + + /// Returns the live parent whose UpdateChild path owns this child. + 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) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 600c251a..dc25b9e8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(e.SourceGfxObjOrSetupId); + DatReaderWriter.DBObjs.Setup? setup = + capturedDats?.Get( + 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(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 } /// - /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the - /// (guid, scriptId) pair into - /// 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. /// /// /// F754 remains a direct PhysicsScript DID. It is never resolved through a @@ -6580,27 +6605,7 @@ public sealed class GameWindow : IDisposable /// 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(); diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs new file mode 100644 index 00000000..ccedb73a --- /dev/null +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -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; + +/// +/// 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. +/// +/// +/// Network pending delivery follows retail +/// SmartBox::HandlePlayScriptID (0x00452020) and +/// SmartBox::HandlePlayScriptType (0x00452070): +/// queue only while the object is absent; an existing cell-less object no-ops. +/// Typed/default playback ports CPhysicsObj::play_script +/// (0x00513260) and both play_default_script overloads +/// (0x005132B0, 0x00513300). +/// +public sealed class EntityEffectController : IAnimationHookSink +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly PhysicsScriptRunner _runner; + private readonly PhysicsScriptTableResolver _tables; + private readonly Func _childAtPart; + private readonly Func _parentOfAttachedChild; + private readonly Action _ownerUnregistered; + private readonly Action _ownerSoundTableChanged; + private readonly Dictionary _profilesByLocalId = new(); + private readonly HashSet _readyServerGuids = new(); + private readonly Dictionary> _pendingByServerGuid = new(); + private readonly Dictionary _staticOwners = new(); + private readonly HashSet _syntheticOwners = new(); + private Action? _diagnosticSink = Console.WriteLine; + + public EntityEffectController( + LiveEntityRuntime liveEntities, + PhysicsScriptRunner runner, + PhysicsScriptTableResolver tables, + Func? childAtPart = null, + Func? parentOfAttachedChild = null, + Action? ownerUnregistered = null, + Action? 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? 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)); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Re-publishes network-owned profile resources after a same-generation + /// PhysicsDesc replacement. Retail CPhysicsObj::set_description + /// (0x00514F40) releases and reinstalls stable_id on every + /// description application, including present-zero clearing. + /// + 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); + } + + /// Clears a pending owner that never reached CreateObject. + 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(); + } + + /// Refreshes every live root anchor after animation/movement. + 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; + } + } + + /// + /// Retail CPhysicsObj::update_object (0x00515D40) 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. + /// + 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? queue)) + { + queue = new Queue(); + _pendingByServerGuid.Add(serverGuid, queue); + } + queue.Enqueue(effect); + } + + private void TryReplayPending(uint serverGuid, uint localId) + { + if (!CanStartOwner(localId) + || !_pendingByServerGuid.Remove(serverGuid, out Queue? 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); + } +} diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs index bfd7d3d9..304b44bf 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs @@ -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; } diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index 7a45942c..420b1ce3 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -17,7 +17,8 @@ namespace AcDream.App.Rendering.Vfx; /// public sealed record ScriptActivationInfo( uint ScriptId, - IReadOnlyList PartTransforms); + IReadOnlyList PartTransforms, + EntityEffectProfile? EffectProfile = null); /// /// Fires Setup.DefaultScript through @@ -27,8 +28,9 @@ public sealed record ScriptActivationInfo( /// Stops the scripts and live emitters when the entity despawns. /// /// -/// Handles both server-spawned and dat-hydrated entities, always keyed by -/// canonical local entity.Id. The C.1.5a guard that early-returned for +/// Handles both server-spawned and dat-hydrated entities. Live owners use the +/// canonical local entity.Id. Dat-static IDs occupy disjoint, globally +/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for /// ServerGuid == 0 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 _resolver; - private readonly HashSet _legacyPendingOwners = new(); + private readonly Action? _registerDatStaticEffectOwner; + private readonly Action? _unregisterDatStaticEffectOwner; + private readonly Dictionary _activeStaticOwners = new(); - /// Already-shipped runner from C.1. Owns the - /// (scriptId, entityId) instance table and schedules hooks at their - /// StartTime offsets. + /// Per-owner retail FIFO that schedules every + /// hook at its absolute script StartTime. /// Already-shipped hook sink from C.1. The /// activator pushes per-entity rotation + part transforms here, and /// calls to drop @@ -70,7 +73,9 @@ public sealed class EntityScriptActivator public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, - Func resolver) + Func resolver, + Action? registerDatStaticEffectOwner = null, + Action? 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; } /// /// Resolve the entity's Setup.DefaultScript and fire it through - /// the script runner, keyed by canonical local entity.Id. + /// the script runner under its canonical runtime identity. /// No-op if the entity has no DefaultScript (resolver returns null /// or zero-script). /// @@ -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); } /// /// 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. /// - 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); } - /// - /// 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. - /// - 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; - } - - /// - /// 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. - /// - public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId) - { - if (serverGuid == 0 || serverGuid == canonicalLocalId) - return; - if (!_legacyPendingOwners.Remove(serverGuid)) - return; - OnRemove(serverGuid); - } - - /// Stops every pre-Create F754 alias at session teardown. - public void ClearLegacyPendingOwners() - { - foreach (uint serverGuid in _legacyPendingOwners) - OnRemove(serverGuid); - _legacyPendingOwners.Clear(); - } - - public int LegacyPendingOwnerCount => _legacyPendingOwners.Count; } diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 2a977516..346b6441 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -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); } } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 39b1024d..7fa62ff2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -604,15 +604,18 @@ public sealed class LiveEntityRuntime _inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence); /// - /// 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 CPhysicsObj::update_object (0x00515D40) 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. /// 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; /// /// Claims the one logical top-level spawn notification for this object diff --git a/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs b/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs index 1283a8a3..0adc32dd 100644 --- a/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs +++ b/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs @@ -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), }); } diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index 071dfda9..f1755a41 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -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. } } diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs index 03f1da33..2f475914 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -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; /// -/// Retail-verbatim port of the AC PhysicsScript runtime — -/// a time-ordered list of s scheduled by -/// (seconds from script -/// start). Every visible effect the server triggers via the -/// PlayScript opcode (0xF754) flows through this runner: -/// spell casts, emote gestures, combat flinches, AND — per the -/// 2026-04-23 lightning research — weather lightning flashes. -/// -/// -/// Decompile provenance (see -/// docs/research/2026-04-23-physicsscript.md and -/// docs/research/2026-04-23-lightning-real.md): -/// -/// FUN_0051bed0play_script(scriptId) -/// public API: resolves the dat id, allocates a script node, inserts -/// into the owner PhysicsObj's linked list at +0x30. -/// -/// FUN_0051be40ScriptManager::Start: -/// allocates the {startTime, script*, next} 16-byte node. -/// -/// FUN_0051bf20 — advances one hook, -/// schedules the next fire time based on the next hook's -/// StartTime. -/// -/// FUN_0051bfb0 — per-frame tick: while -/// head.NextHookAbsTime <= globalClock, fire hooks via -/// vtable dispatch on the owner PhysicsObj. -/// -/// -/// -/// -/// -/// Design choices vs retail: -/// -/// Flat list, not a linked list — iteration is -/// simpler and N is small (< 100 active scripts in practice). -/// -/// Scripts are keyed by (scriptId, entityId) -/// — same pair re-played replaces the old instance so we don't -/// stack duplicates when the server retriggers. -/// -/// The anchor world position is cached at spawn -/// time. For long-running scripts on moving entities, the caller -/// can again with a fresh position each -/// frame — idempotent. -/// -/// -/// +/// 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. /// +/// +/// Port of ScriptManager::AddScriptInternal (0x0051B310), +/// ScriptManager::NextHook (0x0051B3F0), and +/// ScriptManager::UpdateScripts (0x0051B480). +/// Delayed nested calls follow CPhysicsObj::CallPES +/// (0x00511AF0). +/// public sealed class PhysicsScriptRunner { + public const float ImmediateCallPesThresholdSeconds = 0.0002f; + private readonly Func _resolver; private readonly IAnimationHookSink _sink; + private readonly Func _clock; + private readonly Func _randomUnit; + private readonly Func _canAdvanceOwner; private readonly Dictionary _scriptCache = new(); + private readonly Dictionary _owners = new(); + private readonly Dictionary _ownerAnchors = new(); + private readonly List _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 _active = new(); - private double _now; // absolute runtime in seconds - - /// - /// When ACDREAM_DUMP_PLAYSCRIPT=1 is set in the environment, - /// every call and every hook fire prints a line - /// prefixed with [pes]. Use this to confirm the server is - /// delivering PlayScript opcodes (lightning, spell casts, emotes) - /// and which script IDs those are. Off by default. - /// - public bool DiagEnabled { get; set; } = - System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1"; - - /// - /// 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. - /// - public PhysicsScriptRunner(Func resolver, IAnimationHookSink sink) + public PhysicsScriptRunner( + Func resolver, + IAnimationHookSink sink, + Func? clock = null, + Func? randomUnit = null, + Func? 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); } - /// Number of scripts currently active (for telemetry). - public int ActiveScriptCount => _active.Count; + /// Queued PhysicsScripts across every owner, including tails. + public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count); - /// - /// Start (or restart) a PhysicsScript on the given entity. - /// Retail-equivalent of PhysicsObj::play_script. Returns - /// true if the script was found and queued, false - /// if the dat lookup failed. Replaying the same - /// (scriptId, entityId) pair replaces the prior instance - /// instead of stacking. - /// - 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"; + + /// Always-on structural/load diagnostics; production supplies a logger. + public Action? DiagnosticSink { get; set; } + + /// Updates the root anchor used when this owner's hooks dispatch. + public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition) { - if (scriptId == 0) return false; + if (ownerLocalId != 0) + _ownerAnchors[ownerLocalId] = worldPosition; + } - var script = ResolveScript(scriptId); + /// Enqueues a direct F754/Setup PhysicsScript DID. + 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? immediateAncestors = null; + if (_dispatchContext is { } dispatch + && dispatch.OwnerLocalId == ownerLocalId + && start <= dispatch.Script.StartTime) + { + immediateAncestors = dispatch.Script.ImmediateAncestors is null + ? new HashSet { dispatch.Script.ScriptDid } + : new HashSet(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) + /// + /// 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. + /// + 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); } /// - /// Advance every active script by . - /// Fires each hook whose - /// (measured from the script's 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 + /// [0, maximumPause] and retains the call until due. /// - 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; + } + + /// + /// Publishes the current frame clock before animation hooks are delivered, + /// without running retail's timed-hook/script update pass. + /// + public void PublishTime(double gameTime) + { + ValidateMonotonicTime(gameTime); + _gameTime = gameTime; + _frameTimePublished = true; + } + + /// Advances to an absolute game-clock timestamp. + 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) + /// Compatibility delta-time overload used by existing callers. + 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); } - /// - /// Stop an active script instance by - /// (scriptId, entityId). Used for cleanup when an entity - /// despawns. Not necessary to call on normal script completion — - /// scripts self-remove via . - /// - 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); - } - } - - /// Stop all scripts on an entity (e.g. on despawn). - 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; } - /// - /// 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). - /// - 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 Scripts { get; } = new(); + } + + private sealed class ScheduledScript + { + public ScheduledScript( + uint scriptDid, + DatPhysicsScript script, + double startTime, + HashSet? 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? 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); } diff --git a/src/AcDream.Core/Vfx/PhysicsScriptTableResolver.cs b/src/AcDream.Core/Vfx/PhysicsScriptTableResolver.cs index a9b6815c..6d4b5fc0 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptTableResolver.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptTableResolver.cs @@ -24,11 +24,33 @@ public sealed class PhysicsScriptTableResolver /// type, an unordered/above-range intensity, or an invalid script DID. /// public uint? Resolve(uint tableDid, uint rawScriptType, float intensity) + => Resolve(tableDid, rawScriptType, intensity, out _); + + /// + /// 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. + /// + 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( diff --git a/src/AcDream.Core/World/InteriorEntityIdAllocator.cs b/src/AcDream.Core/World/InteriorEntityIdAllocator.cs index cc4cecff..ffebae65 100644 --- a/src/AcDream.Core/World/InteriorEntityIdAllocator.cs +++ b/src/AcDream.Core/World/InteriorEntityIdAllocator.cs @@ -37,8 +37,8 @@ public static class InteriorEntityIdAllocator { /// 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. + /// exactly the #190 bug, just at a higher threshold. + /// fails before that can happen. public const uint MaxCounter = 0xFFFu; /// The first id in this landblock's reserved range (counter=0). @@ -46,4 +46,18 @@ public static class InteriorEntityIdAllocator /// within the landblock. public static uint Base(uint landblockX, uint landblockY) => 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12); + + /// + /// Allocates the next canonical interior-static ID and advances + /// . The full 12-bit range, including + /// , is valid; the next request fails rather than + /// aliasing the following landblock. + /// + 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++; + } } diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index 80a47695..00400c6c 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -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, diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs new file mode 100644 index 00000000..df87949b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -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 _scripts = new(); + private readonly Dictionary _tables = new(); + private EntityEffectController? _controller; + + public Fixture( + Func? childAtPart = null, + Func? parentOfAttachedChild = null, + Func? tableLoader = null, + Action? ownerUnregistered = null, + Action? ownerSoundTableChanged = null) + { + Spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + 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())); + 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())); + 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(); + 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(call.Hook), + call => Assert.Equal(4u, Assert.IsType(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(); + 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(); + 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(), + }; + + 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(), + Array.Empty(), + Array.Empty(), + 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(); +} diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectProfileTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectProfileTests.cs index 0a7d8331..a02b3852 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectProfileTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectProfileTests.cs @@ -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, }; } diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs similarity index 87% rename from tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs rename to tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 98277a51..a0a830d1 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -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( + () => 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(), + }; + + 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(), + }; - // 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); - } } diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs similarity index 98% rename from tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs rename to tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs index c5aef6e4..e263ff07 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -11,7 +11,7 @@ using DatReaderWriter.Types; using Xunit; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; -namespace AcDream.Core.Tests.Streaming; +namespace AcDream.App.Tests.Streaming; /// /// Phase C.1.5b: verifies 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); } diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index f40bee01..d06a39a1 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -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())); - 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() { diff --git a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs index d4119641..0a330648 100644 --- a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs +++ b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs @@ -18,9 +18,15 @@ public sealed class RetailDatLoaderTests private sealed class RawDatabase : IDatDatabase { private readonly Dictionary _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 GetAllIdsOfType() where T : IDBObj => _entries.Keys; public bool TryGet(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(() => 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(() => RetailPhysicsScriptLoader.Parse(script)); + } + [Fact] public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects() { diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs index d86cb57b..3f77c003 100644 --- a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs @@ -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 { - /// - /// Recording sink so tests can assert each hook dispatch. - /// 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? 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? clock = null, + Func? randomUnit = null, + Func? canAdvanceOwner = null, + params (uint Id, DatPhysicsScript Script)[] scripts) { - // Build an in-memory resolver from the script table — no DatCollection needed. - var table = new Dictionary(); - 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(); + 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(); + 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(); + 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(() => runner.Tick(0.5)); + Assert.Throws(() => runner.Tick(double.NaN)); + Assert.Throws(() => runner.Tick(double.PositiveInfinity)); + } + + private static uint[] EmitterIds(RecordingSink sink) => + sink.Calls + .Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId) + .ToArray(); } diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptTableResolverTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptTableResolverTests.cs index 80833a81..956ffde4 100644 --- a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptTableResolverTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptTableResolverTests.cs @@ -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(failure); + Assert.Equal("fixture DAT failure", invalid.Message); + } + private static PhysicsScriptTable BuildTable( params (float Mod, uint ScriptDid)[] entries) { diff --git a/tests/AcDream.Core.Tests/World/InteriorEntityIdAllocatorTests.cs b/tests/AcDream.Core.Tests/World/InteriorEntityIdAllocatorTests.cs index bdbb32cc..f1ced564 100644 --- a/tests/AcDream.Core.Tests/World/InteriorEntityIdAllocatorTests.cs +++ b/tests/AcDream.Core.Tests/World/InteriorEntityIdAllocatorTests.cs @@ -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( + () => InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter)); + Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal); + } } diff --git a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs index c87ecddd..baa89db2 100644 --- a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs @@ -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( + () => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u)); + + Assert.Contains("255-entry", error.Message, StringComparison.Ordinal); + } + [Fact] public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue() {