feat(vfx): port retail effect scheduling and delivery

This commit is contained in:
Erik 2026-07-14 09:25:44 +02:00
parent 363e046112
commit 96ddfdf175
28 changed files with 2473 additions and 691 deletions

View file

@ -277,6 +277,24 @@ Chorizite.DatReaderWriter, and replace only its incomplete
payload. Core consumes the loaders through delegates/interfaces and therefore payload. Core consumes the loaders through delegates/interfaces and therefore
does not depend on Content or a database implementation. 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 The remaining aggregation is primarily `_playerController`'s player-specific
movement plus the separate `WorldEntity`/animation/physics component types. movement plus the separate `WorldEntity`/animation/physics component types.
Those should become ONE class: Those should become ONE class:

View file

@ -232,8 +232,14 @@ instead of spread across `GameWindow`.
latest accepted immutable CreateObject snapshot, owns the canonical local ID latest accepted immutable CreateObject snapshot, owns the canonical local ID
and optional runtime components, and separates logical registration from and optional runtime components, and separates logical registration from
spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved
relations by child and parent generation. A future general mixed packet queue relations by child and parent generation. `Rendering/Vfx/EntityEffectController`
still has to cover the non-Parent packet families tracked by divergence AD-32. 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. leave/re-entry restores presentation without duplicating plugin event replay.
**Remaining target:** the player-specific controller is still a separate **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 **Behavior change:** Spatial withdrawal and re-entry now preserve logical
identity and active resources instead of replaying create-time effects. identity and active resources instead of replaying create-time effects.

View file

@ -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 | | # | 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-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-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-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-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-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) | | 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 | | # | 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-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-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-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-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-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 | | 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-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-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-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-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a | | AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | | 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-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-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-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 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 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-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` | | 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 | | # | 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-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-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-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-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-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.71.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 | | TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.71.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. 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. 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. 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. 10. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
11. **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.
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.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** **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), 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 and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId). Membership Stage 2 must land TS-18 (BuildingCellId).
The audio phase lands TS-9/TS-29; the animation-hook layer lands The audio phase lands TS-9/TS-29; the live-pose animation-hook layer lands
TS-10/TS-11/TS-12/TS-13/TS-14. TS-10/TS-11/TS-12/TS-14.
--- ---

View file

@ -500,7 +500,7 @@ behavior. Estimated 1726 days focused work, 35 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 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`. - **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`. - **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 03 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 04 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 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. - **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`. - **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`.

View file

@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 03 - **M2/M3 missile, effect, and portal presentation campaign (Steps 04
implemented and independently reviewed 2026-07-14)** — named-retail projectile and implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and 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 cannot replay create-time scripts. A narrow raw-hook seam now decodes retail's
inherited `CreateBlockingParticle` payload for both PhysicsScripts and inherited `CreateBlockingParticle` payload for both PhysicsScripts and
Animations, while exact stored-order `PhysicsScriptTable` threshold lookup and Animations, while exact stored-order `PhysicsScriptTable` threshold lookup and
Setup-versus-network default precedence are pinned by conformance tests. The Setup-versus-network default precedence are pinned by conformance tests.
remaining steps port per-owner effect scheduling, live animated attachment `EntityEffectController` now replays pre-create F754/F755 once in mixed order
poses, projectile motion and sweeps, and Hidden/UnHide portal presentation. 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)** - **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 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 funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -9,6 +9,7 @@ using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -28,6 +29,9 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly Func<ParentEvent.Parsed, bool> _acceptParent; private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments; private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary>
public event Action<uint>? EntityReady;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new(); private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
public IEnumerable<uint> AttachedEntityIds public IEnumerable<uint> AttachedEntityIds
@ -283,6 +287,43 @@ public sealed class EquippedChildRenderController : IDisposable
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}"); $"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid); Relations.MarkProjected(childGuid);
EntityReady?.Invoke(childGuid);
}
/// <summary>
/// Resolves retail's parent Setup part index to the currently attached
/// child local ID for DefaultScriptPartHook.
/// </summary>
public uint? FindChildLocalIdAtPart(uint parentLocalId, uint partIndex)
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
|| parent.Id != parentLocalId
|| !child.ParentSetup.HoldingLocations.TryGetValue(
child.ParentLocation,
out LocationType? holding)
|| holding.PartId != partIndex)
{
continue;
}
return child.Entity.Id;
}
return null;
}
/// <summary>Returns the live parent whose UpdateChild path owns this child.</summary>
public uint? FindParentLocalId(uint childLocalId)
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (child.Entity.Id != childLocalId)
continue;
if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
return parent.Id;
return null;
}
return null;
} }
private void ResolveRelations(uint childGuid) private void ResolveRelations(uint childGuid)

View file

@ -333,10 +333,13 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink; private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754) // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// from the server and schedules the dat-defined hooks (particle spawns, // and typed PlayScriptType (0xF755) events through one effect owner, then
// sounds, light toggles) at their StartTime offsets. // 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.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
@ -1451,7 +1454,8 @@ public sealed class GameWindow : IDisposable
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats); _physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner( _scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript, _physicsScriptLoader.LoadPhysicsScript,
_particleSink); _hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on // Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up, // 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 // _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows // are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. // 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) AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{ {
try try
{ {
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId); DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null; if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId; uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup); 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 catch
{ {
@ -2312,7 +2322,17 @@ public sealed class GameWindow : IDisposable
} }
} }
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( 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; _entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
@ -2336,7 +2356,7 @@ public sealed class GameWindow : IDisposable
{ {
try try
{ {
entityScriptActivator.OnRemove(entity.Id); entityScriptActivator.OnRemove(entity);
} }
finally finally
{ {
@ -2352,6 +2372,27 @@ public sealed class GameWindow : IDisposable
_liveEntities, _liveEntities,
TryAcceptParentForRender); TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
_equippedChildRenderer.EntityReady += guid =>
entityEffects.OnLiveEntityReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades); _classificationCache, _translucencyFades);
@ -2575,9 +2616,9 @@ public sealed class GameWindow : IDisposable
} }
finally finally
{ {
// F754 can precede CreateObject, so some temporary owners have no // F754/F755 can precede CreateObject, so the mixed pending FIFO
// LiveEntityRecord for the normal logical teardown to visit. // may contain owners with no LiveEntityRecord to tear down.
_entityScriptActivator?.ClearLegacyPendingOwners(); _entityEffects?.ClearNetworkState();
} }
} }
@ -2617,6 +2658,8 @@ public sealed class GameWindow : IDisposable
// gestures, AND — per Agent #5 research — lightning // gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather. // flashes during stormy weather.
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived; _liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
_liveSession.PlayPhysicsScriptTypeReceived += message =>
_entityEffects?.HandleTyped(message);
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override; // cues. Fog types (0x00..0x06) set WeatherSystem.Override;
@ -3886,16 +3929,6 @@ public sealed class GameWindow : IDisposable
Sequencer = sequencer, 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) 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 // Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown. // waiting for a graceful shutdown.
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0) if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
@ -4007,12 +4045,11 @@ public sealed class GameWindow : IDisposable
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{ {
// A Delete can arrive before CreateObject, in which case no instance // A Delete can arrive before CreateObject, in which case no instance
// timestamp owner exists and the tracked F754 alias must be cleaned // timestamp owner exists and its mixed pending effect FIFO must be
// directly. For a known record, cleanup belongs after the runtime's // discarded directly. For a known record, cleanup belongs after the
// generation gate: a stale Delete must not cancel the current // generation gate: a stale Delete must not cancel the current owner.
// incarnation's effect.
if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true) if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
_entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u); _entityEffects?.ForgetUnknownOwner(delete.Guid);
bool removed = _liveEntities!.UnregisterLiveEntity( bool removed = _liveEntities!.UnregisterLiveEntity(
delete, delete,
isLocalPlayer: delete.Guid == _playerServerGuid, isLocalPlayer: delete.Guid == _playerServerGuid,
@ -4379,6 +4416,7 @@ public sealed class GameWindow : IDisposable
&& effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile) && effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
{ {
liveProfile.ApplyNetworkDescription(refresh.Description); liveProfile.ApplyNetworkDescription(refresh.Description);
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
} }
OnLiveAppearanceUpdated(refresh.Appearance); OnLiveAppearanceUpdated(refresh.Appearance);
@ -4785,15 +4823,7 @@ public sealed class GameWindow : IDisposable
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record) private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{ {
// AD-32 transitional F754 ownership: before Step 4's mixed pending _entityEffects?.OnLiveEntityUnregistered(record);
// 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);
if (record.WorldEntity is not { } existingEntity) if (record.WorldEntity is not { } existingEntity)
return; return;
@ -6565,13 +6595,8 @@ public sealed class GameWindow : IDisposable
} }
/// <summary> /// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the /// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/> /// GUID translation and pre-materialization queueing.
/// 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.
/// ///
/// <para> /// <para>
/// F754 remains a direct PhysicsScript DID. It is never resolved through a /// F754 remains a direct PhysicsScript DID. It is never resolved through a
@ -6580,27 +6605,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message) private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
{ {
if (_scriptRunner is null) return; _entityEffects?.HandleDirect(message);
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);
} }
private void UpdateSkyPes( private void UpdateSkyPes(
@ -6628,6 +6633,7 @@ public sealed class GameWindow : IDisposable
continue; continue;
uint skyEntityId = SkyPesEntityId(key); uint skyEntityId = SkyPesEntityId(key);
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
@ -6652,6 +6658,7 @@ public sealed class GameWindow : IDisposable
else else
{ {
_missingSkyPes.Add(key); _missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId); _particleSink.ClearEntityRenderPass(skyEntityId);
} }
} }
@ -6663,7 +6670,8 @@ public sealed class GameWindow : IDisposable
continue; continue;
uint skyEntityId = SkyPesEntityId(key); uint skyEntityId = SkyPesEntityId(key);
_scriptRunner.Stop(key.PesObjectId, skyEntityId); _scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true); _particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_activeSkyPes.Remove(key); _activeSkyPes.Remove(key);
} }
@ -6687,8 +6695,8 @@ public sealed class GameWindow : IDisposable
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
} }
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; entity.Id;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes). // #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig; 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 var hydrated = new AcDream.Core.World.WorldEntity
{ {
Id = sceneryIdBase + localIndex++, 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). // why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu; uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu; uint interiorLbY = (landblockId >> 16) & 0xFFu;
uint interiorIdBase = AcDream.Core.World.InteriorEntityIdAllocator.Base(interiorLbX, interiorLbY);
uint localCounter = 0; uint localCounter = 0;
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
@ -7481,20 +7492,12 @@ public sealed class GameWindow : IDisposable
var worldPos = stab.Frame.Origin + lbOffset; var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation; 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 var hydrated = new AcDream.Core.World.WorldEntity
{ {
Id = interiorIdBase + localCounter++, Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
interiorLbX,
interiorLbY,
ref localCounter),
SourceGfxObjOrSetupId = stab.Id, SourceGfxObjOrSetupId = stab.Id,
Position = worldPos, Position = worldPos,
Rotation = worldRot, Rotation = worldRot,
@ -7927,11 +7930,12 @@ public sealed class GameWindow : IDisposable
// over time, and the stacked ties destabilize the 128-cap pool // over time, and the stacked ties destabilize the 128-cap pool
// sort). Clear the owner's previous registration first: the // sort). Clear the owner's previous registration first: the
// re-apply becomes idempotent, and a first apply is a no-op. // re-apply becomes idempotent, and a first apply is a no-op.
_lightingSink.UnregisterOwner(entity.Id); uint effectOwnerId = entity.Id;
_translucencyFades.ClearEntity(entity.Id); // #188 _lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId); // #188
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup, datSetup,
ownerId: entity.Id, ownerId: effectOwnerId,
entityPosition: entity.Position, entityPosition: entity.Position,
entityRotation: entity.Rotation, entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility 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); 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; // [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame // flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
// boundary, independent of where in OnUpdate the applies landed) and reset. // 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 // Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms. // 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) if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds); TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick(); _equippedChildRenderer?.Tick();
@ -9259,7 +9276,8 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering. // debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug) if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_scriptRunner?.Tick((float)deltaSeconds); _entityEffects?.RefreshLiveOwnerAnchors();
_scriptRunner?.Tick(_physicsScriptGameTime);
_particleSystem?.Tick((float)deltaSeconds); _particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload // Phase G.1/G.2: feed the sun, tick LightManager, build + upload
@ -14001,7 +14019,7 @@ public sealed class GameWindow : IDisposable
} }
finally finally
{ {
_entityScriptActivator?.ClearLegacyPendingOwners(); _entityEffects?.ClearNetworkState();
} }
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose(); _wbDrawDispatcher?.Dispose();

View file

@ -0,0 +1,408 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Update-thread owner of live effect profiles, mixed pre-materialization
/// F754/F755 delivery, typed-table resolution, and effect-producing animation
/// hooks. Server GUIDs are translated here; downstream sinks see local IDs.
/// </summary>
/// <remarks>
/// Network pending delivery follows retail
/// <c>SmartBox::HandlePlayScriptID</c> (<c>0x00452020</c>) and
/// <c>SmartBox::HandlePlayScriptType</c> (<c>0x00452070</c>):
/// queue only while the object is absent; an existing cell-less object no-ops.
/// Typed/default playback ports <c>CPhysicsObj::play_script</c>
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
/// </remarks>
public sealed class EntityEffectController : IAnimationHookSink
{
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner;
private readonly PhysicsScriptTableResolver _tables;
private readonly Func<uint, uint, uint?> _childAtPart;
private readonly Func<uint, uint?> _parentOfAttachedChild;
private readonly Action<uint> _ownerUnregistered;
private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
private readonly HashSet<uint> _readyServerGuids = new();
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly HashSet<uint> _syntheticOwners = new();
private Action<string>? _diagnosticSink = Console.WriteLine;
public EntityEffectController(
LiveEntityRuntime liveEntities,
PhysicsScriptRunner runner,
PhysicsScriptTableResolver tables,
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
_childAtPart = childAtPart ?? ((_, _) => null);
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { });
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
}
public Action<string>? DiagnosticSink
{
get => _diagnosticSink;
set => _diagnosticSink = value;
}
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
public int ReadyOwnerCount => _profilesByLocalId.Count;
public void HandleDirect(PlayPhysicsScript message)
{
if (message.Guid == 0)
return;
if (TryGetReadyLocalId(message.Guid, out uint localId))
{
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayDirect(localId, message.ScriptDid);
return;
}
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
}
public void HandleTyped(PlayPhysicsScriptType message)
{
if (message.Guid == 0)
return;
if (TryGetReadyLocalId(message.Guid, out uint localId))
{
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayTyped(localId, message.RawScriptType, message.Intensity);
return;
}
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
}
/// <summary>
/// Marks a live owner ready only after its projection, resource owners, and
/// effect profile have all registered. Pending packets replay once in their
/// original mixed F754/F755 order.
/// </summary>
public bool OnLiveEntityReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| !record.ResourcesRegistered
|| record.EffectProfile is not EntityEffectProfile profile)
{
return false;
}
_readyServerGuids.Add(serverGuid);
_profilesByLocalId[entity.Id] = profile;
_runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
TryReplayPending(serverGuid, entity.Id);
return true;
}
/// <summary>
/// Re-publishes network-owned profile resources after a same-generation
/// PhysicsDesc replacement. Retail <c>CPhysicsObj::set_description</c>
/// (<c>0x00514F40</c>) releases and reinstalls <c>stable_id</c> on every
/// description application, including present-zero clearing.
/// </summary>
public bool OnLiveEntityDescriptionChanged(uint serverGuid)
{
if (!TryGetReadyLocalId(serverGuid, out uint localId)
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.EffectProfile is not EntityEffectProfile profile)
{
return false;
}
_profilesByLocalId[localId] = profile;
_ownerSoundTableChanged(localId, profile.CurrentSoundTableDid);
return true;
}
public void OnDatStaticEntityReady(
uint ownerLocalId,
WorldEntity entity,
EntityEffectProfile profile)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(profile);
if (ownerLocalId == 0)
return;
_staticOwners[ownerLocalId] = entity;
_profilesByLocalId[ownerLocalId] = profile;
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
}
public void OnDatStaticEntityRemoved(uint localId)
{
if (!_staticOwners.Remove(localId))
return;
_profilesByLocalId.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
public void RegisterSyntheticOwner(uint ownerLocalId)
{
if (ownerLocalId != 0)
_syntheticOwners.Add(ownerLocalId);
}
public void UnregisterSyntheticOwner(uint ownerLocalId) =>
_syntheticOwners.Remove(ownerLocalId);
public void OnLiveEntityUnregistered(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
_pendingByServerGuid.Remove(record.ServerGuid);
_readyServerGuids.Remove(record.ServerGuid);
if (record.LocalEntityId is not { } localId)
return;
_profilesByLocalId.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
/// <summary>Clears a pending owner that never reached CreateObject.</summary>
public void ForgetUnknownOwner(uint serverGuid) =>
_pendingByServerGuid.Remove(serverGuid);
public void ClearNetworkState()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
{
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
{
_profilesByLocalId.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
}
_readyServerGuids.Clear();
_pendingByServerGuid.Clear();
}
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
public void RefreshLiveOwnerAnchors()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
{
if (!TryGetReadyLocalId(serverGuid, out uint localId))
continue;
RefreshLiveAnchor(serverGuid, localId);
TryReplayPending(serverGuid, localId);
}
}
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{
if (!CanStartOwner(ownerLocalId))
return false;
return _runner.PlayDirect(ownerLocalId, scriptDid);
}
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
{
if (!CanStartOwner(ownerLocalId)
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)
|| profile.CurrentPhysicsScriptTableDid is not { } tableDid)
{
DiagnosticSink?.Invoke(
$"No PhysicsScriptTable for owner 0x{ownerLocalId:X8}, type 0x{rawScriptType:X8}.");
return false;
}
uint? scriptDid = _tables.Resolve(
tableDid,
rawScriptType,
intensity,
out Exception? loadFailure);
if (scriptDid is not { } resolved)
{
string detail = loadFailure is null
? string.Empty
: $" Load failed: {loadFailure.GetType().Name}: {loadFailure.Message}";
DiagnosticSink?.Invoke(
$"No typed PhysicsScript for owner 0x{ownerLocalId:X8}, table 0x{tableDid:X8}, " +
$"type 0x{rawScriptType:X8}, intensity {intensity:R}.{detail}");
return false;
}
return _runner.PlayDirect(ownerLocalId, resolved);
}
public bool PlayDefault(uint ownerLocalId)
{
if (!CanStartOwner(ownerLocalId)
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile))
return false;
return PlayTyped(
ownerLocalId,
profile.RawDefaultScriptType,
profile.DefaultScriptIntensity);
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
_runner.SetOwnerAnchor(entityId, entityWorldPosition);
switch (hook)
{
case CallPESHook call:
if (CanStartOwner(entityId))
_runner.ScheduleCallPes(entityId, call.PES, call.Pause);
break;
case DefaultScriptHook:
PlayDefault(entityId);
break;
case DefaultScriptPartHook part:
if (_childAtPart(entityId, part.PartIndex) is { } childLocalId)
PlayDefault(childLocalId);
break;
}
}
/// <summary>
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) advances
/// root scripts only with a cell and while not Frozen. Parented objects
/// advance through their eligible parent's UpdateChild path instead of
/// their own cell-less root update.
/// </summary>
public bool CanAdvanceOwner(uint ownerLocalId)
{
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
return true;
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
return CanAdvanceLiveRoot(parentLocalId);
return CanAdvanceLiveRoot(ownerLocalId);
}
private bool CanStartOwner(uint ownerLocalId)
{
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
return true;
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
return IsLiveRootInCell(parentLocalId);
return IsLiveRootInCell(ownerLocalId);
}
private bool CanAdvanceLiveRoot(uint ownerLocalId)
{
if (!TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record))
return false;
return IsLiveRootInCell(record)
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
}
private bool IsLiveRootInCell(uint ownerLocalId) =>
TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record)
&& IsLiveRootInCell(record);
private static bool IsLiveRootInCell(LiveEntityRecord record) =>
record.ResourcesRegistered
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0;
private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record)
{
if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid)
&& _liveEntities.TryGetRecord(serverGuid, out record!))
{
return true;
}
record = null!;
return false;
}
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
{
if (_readyServerGuids.Contains(serverGuid)
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
&& _profilesByLocalId.ContainsKey(localId))
{
return true;
}
localId = 0;
return false;
}
private void RefreshLiveAnchor(uint serverGuid, uint localId)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.WorldEntity is { } entity
&& entity.Id == localId)
{
_runner.SetOwnerAnchor(localId, entity.Position);
}
}
private void Enqueue(uint serverGuid, PendingEffect effect)
{
if (!_pendingByServerGuid.TryGetValue(serverGuid, out Queue<PendingEffect>? queue))
{
queue = new Queue<PendingEffect>();
_pendingByServerGuid.Add(serverGuid, queue);
}
queue.Enqueue(effect);
}
private void TryReplayPending(uint serverGuid, uint localId)
{
if (!CanStartOwner(localId)
|| !_pendingByServerGuid.Remove(serverGuid, out Queue<PendingEffect>? pending))
{
return;
}
while (pending.Count > 0)
Execute(localId, pending.Dequeue());
}
private void Execute(uint localId, PendingEffect effect)
{
if (effect.Kind is PendingEffectKind.Direct)
PlayDirect(localId, effect.ScriptDid);
else
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
}
private enum PendingEffectKind
{
Direct,
Typed,
}
private readonly record struct PendingEffect(
PendingEffectKind Kind,
uint ScriptDid,
uint RawScriptType,
float Intensity)
{
public static PendingEffect Direct(uint scriptDid) =>
new(PendingEffectKind.Direct, scriptDid, 0u, 0f);
public static PendingEffect Typed(uint rawScriptType, float intensity) =>
new(PendingEffectKind.Typed, 0u, rawScriptType, intensity);
}
}

View file

@ -22,10 +22,12 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
{ {
SetupDefaultScriptDid = NormalizePhysicsScriptDid(setup.DefaultScript.DataId); SetupDefaultScriptDid = NormalizePhysicsScriptDid(setup.DefaultScript.DataId);
CurrentPhysicsScriptTableDid = NormalizeTableDid((uint)setup.DefaultScriptTable); CurrentPhysicsScriptTableDid = NormalizeTableDid((uint)setup.DefaultScriptTable);
CurrentSoundTableDid = NormalizeSoundTableDid((uint)setup.DefaultSoundTable);
} }
public uint? SetupDefaultScriptDid { get; } public uint? SetupDefaultScriptDid { get; }
public uint? CurrentPhysicsScriptTableDid { get; private set; } public uint? CurrentPhysicsScriptTableDid { get; private set; }
public uint? CurrentSoundTableDid { get; private set; }
public uint RawDefaultScriptType { get; private set; } public uint RawDefaultScriptType { get; private set; }
public float DefaultScriptIntensity { get; private set; } public float DefaultScriptIntensity { get; private set; }
public bool HasNetworkDescription { get; private set; } public bool HasNetworkDescription { get; private set; }
@ -60,6 +62,8 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
{ {
CurrentPhysicsScriptTableDid = NormalizeTableDid( CurrentPhysicsScriptTableDid = NormalizeTableDid(
physics.PhysicsScriptTableId.GetValueOrDefault()); physics.PhysicsScriptTableId.GetValueOrDefault());
CurrentSoundTableDid = NormalizeSoundTableDid(
physics.SoundTableId.GetValueOrDefault());
RawDefaultScriptType = physics.DefaultScriptType.GetValueOrDefault(); RawDefaultScriptType = physics.DefaultScriptType.GetValueOrDefault();
DefaultScriptIntensity = physics.DefaultScriptIntensity.GetValueOrDefault(); DefaultScriptIntensity = physics.DefaultScriptIntensity.GetValueOrDefault();
HasNetworkDescription = true; HasNetworkDescription = true;
@ -70,4 +74,7 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
private static uint? NormalizeTableDid(uint did) => private static uint? NormalizeTableDid(uint did) =>
PhysicsScriptTableResolver.IsPhysicsScriptTableDid(did) ? did : null; PhysicsScriptTableResolver.IsPhysicsScriptTableDid(did) ? did : null;
private static uint? NormalizeSoundTableDid(uint did) =>
(did & 0xFF000000u) == 0x20000000u ? did : null;
} }

View file

@ -17,7 +17,8 @@ namespace AcDream.App.Rendering.Vfx;
/// </summary> /// </summary>
public sealed record ScriptActivationInfo( public sealed record ScriptActivationInfo(
uint ScriptId, uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms); IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null);
/// <summary> /// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/> /// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
@ -27,8 +28,9 @@ public sealed record ScriptActivationInfo(
/// Stops the scripts and live emitters when the entity despawns. /// Stops the scripts and live emitters when the entity despawns.
/// ///
/// <para> /// <para>
/// Handles both server-spawned and dat-hydrated entities, always keyed by /// Handles both server-spawned and dat-hydrated entities. Live owners use the
/// canonical local <c>entity.Id</c>. The C.1.5a guard that early-returned for /// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects /// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not /// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript. /// the network) also fire their DefaultScript.
@ -52,11 +54,12 @@ public sealed class EntityScriptActivator
private readonly PhysicsScriptRunner _scriptRunner; private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink; private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly HashSet<uint> _legacyPendingOwners = new(); private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the /// <param name="scriptRunner">Per-owner retail FIFO that schedules every
/// (scriptId, entityId) instance table and schedules hooks at their /// hook at its absolute script <c>StartTime</c>.</param>
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The /// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator pushes per-entity rotation + part transforms here, and /// activator pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop /// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
@ -70,7 +73,9 @@ public sealed class EntityScriptActivator
public EntityScriptActivator( public EntityScriptActivator(
PhysicsScriptRunner scriptRunner, PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink, ParticleHookSink particleSink,
Func<WorldEntity, ScriptActivationInfo?> resolver) Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null)
{ {
ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink); ArgumentNullException.ThrowIfNull(particleSink);
@ -78,11 +83,13 @@ public sealed class EntityScriptActivator
_scriptRunner = scriptRunner; _scriptRunner = scriptRunner;
_particleSink = particleSink; _particleSink = particleSink;
_resolver = resolver; _resolver = resolver;
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
} }
/// <summary> /// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through /// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner, keyed by canonical local <c>entity.Id</c>. /// the script runner under its canonical runtime identity.
/// No-op if the entity has no DefaultScript (resolver returns null /// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script). /// or zero-script).
/// </summary> /// </summary>
@ -91,9 +98,17 @@ public sealed class EntityScriptActivator
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id; uint key = entity.Id;
if (key == 0) return; // malformed entity 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); 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 // Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the // (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. // in a multi-part Setup collapses to the entity root.
_particleSink.SetEntityPartTransforms(key, info.PartTransforms); _particleSink.SetEntityPartTransforms(key, info.PartTransforms);
_scriptRunner.Play(info.ScriptId, key, entity.Position); if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
_registerDatStaticEffectOwner?.Invoke(
key,
entity,
profile);
if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position);
} }
/// <summary> /// <summary>
/// Stop every script instance the runner is tracking for this key, and /// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to the canonical /// kill every live emitter the sink has attributed to the runtime effect
/// local entity id. Idempotent for unknown keys. /// owner. Idempotent for unknown entities.
/// </summary> /// </summary>
public void OnRemove(uint key) public void OnRemove(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
if (key == 0) return; if (key == 0) return;
if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
{
return;
}
_scriptRunner.StopAllForEntity(key); _scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false); _particleSink.StopAllForEntity(key, fadeOut: false);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
} }
/// <summary>
/// Starts the temporary server-GUID owner used when F754 precedes the
/// target's CreateObject/materialization. Step 4 replaces this with the
/// retail mixed pending-packet FIFO. Tracking is required because no
/// LiveEntityRecord may exist yet for delete/session teardown.
/// </summary>
public bool PlayLegacyPending(
uint serverGuid,
uint scriptDid,
Vector3 anchorWorldPosition)
{
if (serverGuid == 0)
return false;
bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition);
if (played)
_legacyPendingOwners.Add(serverGuid);
return played;
}
/// <summary>
/// Cleans the temporary server-GUID owner used when F754 arrives before a
/// live projection has a canonical local ID. Step 4 replaces this alias
/// with the retail mixed pending-packet FIFO; until then teardown must stop
/// the alias as well as the normal local owner so an early effect cannot
/// outlive its object incarnation.
/// </summary>
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
{
if (serverGuid == 0 || serverGuid == canonicalLocalId)
return;
if (!_legacyPendingOwners.Remove(serverGuid))
return;
OnRemove(serverGuid);
}
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
public void ClearLegacyPendingOwners()
{
foreach (uint serverGuid in _legacyPendingOwners)
OnRemove(serverGuid);
_legacyPendingOwners.Clear();
}
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
} }

View file

@ -365,7 +365,7 @@ public sealed class GpuWorldState
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)
{ {
if (entity.ServerGuid == 0) 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) foreach (var entity in lb.Entities)
{ {
if (entity.ServerGuid == 0) if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id); _entityScriptActivator.OnRemove(entity);
} }
} }

View file

@ -604,15 +604,18 @@ public sealed class LiveEntityRuntime
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence); _inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
/// <summary> /// <summary>
/// Retail runs root movement/animation only while the object has world-cell /// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
/// membership. A pickup or parent transition keeps script/effect owners but /// movement/animation only while the object has visible world-cell
/// clears the cell and withdraws the root projection until a fresh Position /// membership and is not Frozen. A pickup or parent transition keeps
/// re-enters it. /// script/effect owners but clears the cell and withdraws the root
/// projection until a fresh Position re-enters it.
/// </summary> /// </summary>
public bool ShouldAdvanceRootRuntime(uint serverGuid) => public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.IsSpatiallyProjected && record.IsSpatiallyProjected
&& record.FullCellId != 0; && record.IsSpatiallyVisible
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
/// <summary> /// <summary>
/// Claims the one logical top-level spawn notification for this object /// Claims the one logical top-level spawn notification for this object

View file

@ -82,9 +82,13 @@ public sealed class RetailPhysicsScriptLoader
uint count = reader.ReadUInt32(); uint count = reader.ReadUInt32();
for (uint i = 0; i < count; i++) 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 script.ScriptData.Add(new PhysicsScriptData
{ {
StartTime = reader.ReadDouble(), StartTime = startTime,
Hook = RetailAnimationHookReader.Read(reader), Hook = RetailAnimationHookReader.Read(reader),
}); });
} }

View file

@ -133,8 +133,8 @@ public sealed class ParticleHookSink : IAnimationHookSink
_system.StopEmitter(handleToStop, fadeOut: true); _system.StopEmitter(handleToStop, fadeOut: true);
break; break;
// DefaultScript / CallPES are routed by the entity-effect owner in // DefaultScript / CallPES are routed by EntityEffectController.
// Step 4. ParticleHookSink intentionally owns particles only. // ParticleHookSink intentionally owns particles only.
} }
} }

View file

@ -1,273 +1,400 @@
using System;
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter.Types; 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; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Vfx; namespace AcDream.Core.Vfx;
/// <summary> /// <summary>
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime — /// Retail PhysicsScript scheduler: one serial FIFO per owning physics object.
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by /// Duplicate plays append, different owners progress independently, and a
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script /// catch-up tick drains every due hook in order.
/// start). Every visible effect the server triggers via the
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
/// spell casts, emote gestures, combat flinches, AND — per the
/// 2026-04-23 lightning research — weather lightning flashes.
///
/// <para>
/// Decompile provenance (see
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
/// <list type="bullet">
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
/// public API: resolves the dat id, allocates a script node, inserts
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
/// </description></item>
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
/// </description></item>
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
/// schedules the next fire time based on the next hook's
/// <c>StartTime</c>.
/// </description></item>
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
/// <c>head.NextHookAbsTime &lt;= globalClock</c>, fire hooks via
/// vtable dispatch on the owner <c>PhysicsObj</c>.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// <b>Design choices vs retail:</b>
/// <list type="bullet">
/// <item><description>Flat list, not a linked list — iteration is
/// simpler and N is small (&lt; 100 active scripts in practice).
/// </description></item>
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
/// — same pair re-played replaces the old instance so we don't
/// stack duplicates when the server retriggers.
/// </description></item>
/// <item><description>The anchor world position is cached at spawn
/// time. For long-running scripts on moving entities, the caller
/// can <see cref="Play"/> again with a fresh position each
/// frame — idempotent.
/// </description></item>
/// </list>
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// Port of <c>ScriptManager::AddScriptInternal</c> (<c>0x0051B310</c>),
/// <c>ScriptManager::NextHook</c> (<c>0x0051B3F0</c>), and
/// <c>ScriptManager::UpdateScripts</c> (<c>0x0051B480</c>).
/// Delayed nested calls follow <c>CPhysicsObj::CallPES</c>
/// (<c>0x00511AF0</c>).
/// </remarks>
public sealed class PhysicsScriptRunner public sealed class PhysicsScriptRunner
{ {
public const float ImmediateCallPesThresholdSeconds = 0.0002f;
private readonly Func<uint, DatPhysicsScript?> _resolver; private readonly Func<uint, DatPhysicsScript?> _resolver;
private readonly IAnimationHookSink _sink; private readonly IAnimationHookSink _sink;
private readonly Func<double> _clock;
private readonly Func<double> _randomUnit;
private readonly Func<uint, bool> _canAdvanceOwner;
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new(); private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
private readonly Dictionary<uint, OwnerQueue> _owners = new();
private readonly Dictionary<uint, Vector3> _ownerAnchors = new();
private readonly List<DelayedCallPes> _delayedCalls = new();
private double _gameTime;
private long _tickGeneration;
private bool _frameTimePublished;
private bool _processingTimedHooks;
private DispatchContext? _dispatchContext;
// One active node per (scriptId, entityId) pair. Replaying replaces. public PhysicsScriptRunner(
private readonly List<ActiveScript> _active = new(); Func<uint, DatPhysicsScript?> resolver,
private double _now; // absolute runtime in seconds IAnimationHookSink sink,
Func<double>? clock = null,
/// <summary> Func<double>? randomUnit = null,
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment, Func<uint, bool>? canAdvanceOwner = null)
/// every <see cref="Play"/> call and every hook fire prints a line
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
/// and which script IDs those are. Off by default.
/// </summary>
public bool DiagEnabled { get; set; } =
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <summary>
/// Preferred ctor — resolver delegate lets this class stay
/// DAT-reader-free for testing. Production passes the centralized retail
/// compatibility loader so CreateBlockingParticle cannot misalign hooks.
/// </summary>
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
{ {
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_sink = sink ?? throw new ArgumentNullException(nameof(sink)); _sink = sink ?? throw new ArgumentNullException(nameof(sink));
_clock = clock ?? (() => _gameTime);
_randomUnit = randomUnit ?? Random.Shared.NextDouble;
_canAdvanceOwner = canAdvanceOwner ?? (_ => true);
} }
/// <summary>Number of scripts currently active (for telemetry).</summary> /// <summary>Queued PhysicsScripts across every owner, including tails.</summary>
public int ActiveScriptCount => _active.Count; public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count);
/// <summary> public int ActiveOwnerCount => _owners.Count;
/// Start (or restart) a PhysicsScript on the given entity. public int ScheduledCallPesCount => _delayedCalls.Count;
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
/// <c>true</c> if the script was found and queued, <c>false</c> public bool DiagEnabled { get; set; } =
/// if the dat lookup failed. Replaying the same Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
/// instead of stacking. /// <summary>Always-on structural/load diagnostics; production supplies a logger.</summary>
/// </summary> public Action<string>? DiagnosticSink { get; set; }
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
/// <summary>Updates the root anchor used when this owner's hooks dispatch.</summary>
public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition)
{ {
if (scriptId == 0) return false; if (ownerLocalId != 0)
_ownerAnchors[ownerLocalId] = worldPosition;
}
var script = ResolveScript(scriptId); /// <summary>Enqueues a direct F754/Setup PhysicsScript DID.</summary>
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{
if (ownerLocalId == 0 || scriptDid == 0)
return false;
DatPhysicsScript? script = ResolveScript(scriptDid);
if (script is null || script.ScriptData.Count == 0) if (script is null || script.ScriptData.Count == 0)
{ {
ReportDiagnostic(
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} is missing or empty.");
if (DiagEnabled) 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; return false;
} }
// Dedupe: if this (scriptId, entityId) already has an active if (!_owners.TryGetValue(ownerLocalId, out OwnerQueue? owner))
// 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 (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId) owner = new OwnerQueue();
_active.RemoveAt(i); _owners.Add(ownerLocalId, owner);
} }
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0); double start = owner.Scripts.Count == 0
? _clock()
: owner.Scripts[^1].StartTime + owner.Scripts[^1].Duration;
if (!double.IsFinite(start))
{
ReportDiagnostic(
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} produced a non-finite start time.");
return false;
}
HashSet<uint>? immediateAncestors = null;
if (_dispatchContext is { } dispatch
&& dispatch.OwnerLocalId == ownerLocalId
&& start <= dispatch.Script.StartTime)
{
immediateAncestors = dispatch.Script.ImmediateAncestors is null
? new HashSet<uint> { dispatch.Script.ScriptDid }
: new HashSet<uint>(dispatch.Script.ImmediateAncestors) { dispatch.Script.ScriptDid };
if (immediateAncestors.Contains(scriptDid))
{
ReportDiagnostic(
$"Rejected zero-time recursive PhysicsScript 0x{scriptDid:X8} for owner " +
$"0x{ownerLocalId:X8}; DAT CallPES chain would never yield.");
return false;
}
}
owner.Scripts.Add(new ScheduledScript(
scriptDid,
script,
start,
immediateAncestors,
EarliestScriptTickGeneration(ownerLocalId)));
if (DiagEnabled) if (DiagEnabled)
{ Console.WriteLine($"[pes] enqueue script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8} start={start:R} depth={owner.Scripts.Count}");
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}");
}
return true; return true;
} }
private void AddActiveScript( /// <summary>
DatPhysicsScript script, /// Compatibility seam for existing static/sky callers. The scheduler is
uint scriptId, /// still keyed by canonical owner ID; the position merely refreshes its
uint entityId, /// hook anchor before enqueue.
Vector3 anchorWorldPos, /// </summary>
float delaySeconds) public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
{ {
_active.Add(new ActiveScript SetOwnerAnchor(entityId, anchorWorldPos);
{ return PlayDirect(entityId, scriptId);
Script = script,
ScriptId = scriptId,
EntityId = entityId,
AnchorWorld = anchorWorldPos,
StartTimeAbs = _now + Math.Max(0f, delaySeconds),
NextHookIndex = 0,
});
} }
/// <summary> /// <summary>
/// Advance every active script by <paramref name="dtSeconds"/>. /// Implements retail CallPES. Near-zero pauses append immediately; a
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/> /// positive pause samples a deterministic injectable uniform delay in
/// (measured from the script's <see cref="Play"/> moment) has been /// <c>[0, maximumPause]</c> and retains the call until due.
/// reached. Removes scripts that have finished all their hooks.
/// </summary> /// </summary>
public void Tick(float dtSeconds) public bool ScheduleCallPes(uint ownerLocalId, uint scriptDid, float maximumPause)
{ {
if (dtSeconds < 0) dtSeconds = 0; if (ownerLocalId == 0 || scriptDid == 0 || !float.IsFinite(maximumPause))
_now += dtSeconds; return false;
if (maximumPause < ImmediateCallPesThresholdSeconds)
return PlayDirect(ownerLocalId, scriptDid);
// Back-to-front so RemoveAt() is cheap and safe mid-iteration. double sample = _randomUnit();
for (int i = _active.Count - 1; i >= 0; i--) if (!double.IsFinite(sample))
return false;
sample = Math.Clamp(sample, 0.0, 1.0);
double due = _clock() + sample * maximumPause;
_delayedCalls.Add(new DelayedCallPes(
ownerLocalId,
scriptDid,
due,
EarliestTickGenerationForNewTimedHook()));
return true;
}
/// <summary>
/// Publishes the current frame clock before animation hooks are delivered,
/// without running retail's timed-hook/script update pass.
/// </summary>
public void PublishTime(double gameTime)
{
ValidateMonotonicTime(gameTime);
_gameTime = gameTime;
_frameTimePublished = true;
}
/// <summary>Advances to an absolute game-clock timestamp.</summary>
public void Tick(double gameTime)
{
ValidateMonotonicTime(gameTime);
_gameTime = gameTime;
_frameTimePublished = false;
_tickGeneration++;
DrainDueCallPes(gameTime);
// Snapshot owner IDs. Hooks may append to their current owner, create a
// different owner, or delete an owner without invalidating iteration.
uint[] ownerIds = _owners.Keys.ToArray();
for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++)
{ {
var a = _active[i]; uint ownerId = ownerIds[ownerIndex];
double elapsed = _now - a.StartTimeAbs; if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner))
continue;
if (!_canAdvanceOwner(ownerId))
continue;
DrainOwner(ownerId, owner, gameTime);
}
}
// Fire every hook whose scheduled time has arrived. /// <summary>Compatibility delta-time overload used by existing callers.</summary>
while (a.NextHookIndex < a.Script.ScriptData.Count public void Tick(float deltaSeconds)
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed) {
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]; DelayedCallPes call = _delayedCalls[i];
DispatchHook(a, entry.Hook); if (call.DueTime > gameTime
a.NextHookIndex++; || 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) if (current.NextHookIndex < current.Script.ScriptData.Count)
_active.RemoveAt(i); return;
else
_active[i] = a; owner.Scripts.RemoveAt(0);
} }
_owners.Remove(ownerId);
} }
/// <summary> private void DispatchHook(uint ownerId, ScheduledScript script, AnimationHook hook)
/// Stop an active script instance by
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
/// despawns. Not necessary to call on normal script completion —
/// scripts self-remove via <see cref="Tick"/>.
/// </summary>
public void Stop(uint scriptId, uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
public void StopAllForEntity(uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
private void DispatchHook(ActiveScript a, AnimationHook hook)
{ {
if (DiagEnabled) 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( _sink.OnHook(ownerId, anchor, hook);
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
$"hook={hook.HookType}");
} }
finally
// 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)
{ {
// CallPESHook.PES = sub-script id; Pause = delay before the _dispatchContext = previous;
// 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;
} }
_sink.OnHook(a.EntityId, a.AnchorWorld, hook);
} }
private DatPhysicsScript? ResolveScript(uint id) private DatPhysicsScript? ResolveScript(uint id)
{ {
if (_scriptCache.TryGetValue(id, out var cached)) return cached; if (_scriptCache.TryGetValue(id, out DatPhysicsScript? cached))
var script = _resolver(id); 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; _scriptCache[id] = script;
return script; return script;
} }
/// <summary> private long EarliestTickGenerationForNewTimedHook()
/// Test-only seam: pre-seed the resolver cache with a hand-built
/// script so unit tests can exercise the scheduler without loading
/// dats. Production code never calls this (name carries the warning).
/// </summary>
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
=> _scriptCache[id] = script;
private struct ActiveScript
{ {
public DatPhysicsScript Script; // A positive-pause CallPES adds a timed object hook. Retail does not
public uint ScriptId; // revisit the active process_hooks traversal for a hook inserted by
public uint EntityId; // the animation phase, even when its sampled delay is zero. Near-zero
public Vector3 AnchorWorld; // CallPES bypasses this list and may reach the upcoming script pass.
public double StartTimeAbs; return _frameTimePublished
public int NextHookIndex; ? _tickGeneration + 2
: _tickGeneration + 1;
} }
private long EarliestScriptTickGeneration(uint ownerLocalId)
{
if (_processingTimedHooks
|| _dispatchContext is { OwnerLocalId: var dispatchOwner }
&& dispatchOwner == ownerLocalId)
return _tickGeneration;
return _tickGeneration + 1;
}
private void ValidateMonotonicTime(double gameTime)
{
if (!double.IsFinite(gameTime))
throw new ArgumentOutOfRangeException(nameof(gameTime));
if (gameTime < _gameTime)
throw new ArgumentOutOfRangeException(nameof(gameTime), "PhysicsScript time must be monotonic.");
}
private void ReportDiagnostic(string message) => DiagnosticSink?.Invoke(message);
private sealed class OwnerQueue
{
public List<ScheduledScript> Scripts { get; } = new();
}
private sealed class ScheduledScript
{
public ScheduledScript(
uint scriptDid,
DatPhysicsScript script,
double startTime,
HashSet<uint>? immediateAncestors,
long earliestTickGeneration)
{
ScriptDid = scriptDid;
Script = script;
StartTime = startTime;
Duration = script.ScriptData[^1].StartTime;
ImmediateAncestors = immediateAncestors;
EarliestTickGeneration = earliestTickGeneration;
}
public uint ScriptDid { get; }
public DatPhysicsScript Script { get; }
public double StartTime { get; }
public double Duration { get; }
public HashSet<uint>? ImmediateAncestors { get; }
public long EarliestTickGeneration { get; }
public int NextHookIndex { get; set; }
}
private readonly record struct DelayedCallPes(
uint OwnerLocalId,
uint ScriptDid,
double DueTime,
long EarliestTickGeneration);
private sealed record DispatchContext(uint OwnerLocalId, ScheduledScript Script);
} }

View file

@ -24,11 +24,33 @@ public sealed class PhysicsScriptTableResolver
/// type, an unordered/above-range intensity, or an invalid script DID. /// type, an unordered/above-range intensity, or an invalid script DID.
/// </summary> /// </summary>
public uint? Resolve(uint tableDid, uint rawScriptType, float intensity) public uint? Resolve(uint tableDid, uint rawScriptType, float intensity)
=> Resolve(tableDid, rawScriptType, intensity, out _);
/// <summary>
/// Resolves a typed play while preserving a corrupt-DAT exception for the
/// App owner to diagnose with entity context. A load failure remains a
/// retail no-play and never corrupts the caller's script FIFO.
/// </summary>
public uint? Resolve(
uint tableDid,
uint rawScriptType,
float intensity,
out Exception? loadFailure)
{ {
loadFailure = null;
if (!IsPhysicsScriptTableDid(tableDid)) if (!IsPhysicsScriptTableDid(tableDid))
return null; return null;
PhysicsScriptTable? table = _loadTable(tableDid); PhysicsScriptTable? table;
try
{
table = _loadTable(tableDid);
}
catch (Exception error)
{
loadFailure = error;
return null;
}
if (table is null if (table is null
|| table.Id != tableDid || table.Id != tableDid
|| !table.ScriptTable.TryGetValue( || !table.ScriptTable.TryGetValue(

View file

@ -37,8 +37,8 @@ public static class InteriorEntityIdAllocator
{ {
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating /// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
/// more interior entities than this would alias into the next Y-slot — /// more interior entities than this would alias into the next Y-slot —
/// exactly the #190 bug, just at a higher threshold. Callers should log /// exactly the #190 bug, just at a higher threshold. <see cref="Allocate"/>
/// loudly (never silently wrap) if this is ever exceeded.</summary> /// fails before that can happen.</summary>
public const uint MaxCounter = 0xFFFu; public const uint MaxCounter = 0xFFFu;
/// <summary>The first id in this landblock's reserved range (counter=0). /// <summary>The first id in this landblock's reserved range (counter=0).
@ -46,4 +46,18 @@ public static class InteriorEntityIdAllocator
/// within the landblock.</summary> /// within the landblock.</summary>
public static uint Base(uint landblockX, uint landblockY) public static uint Base(uint landblockX, uint landblockY)
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12); => 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
/// <summary>
/// Allocates the next canonical interior-static ID and advances
/// <paramref name="counter"/>. The full 12-bit range, including
/// <see cref="MaxCounter"/>, is valid; the next request fails rather than
/// aliasing the following landblock.
/// </summary>
public static uint Allocate(uint landblockX, uint landblockY, ref uint counter)
{
if (counter > MaxCounter)
throw new InvalidDataException(
$"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry interior entity id namespace.");
return Base(landblockX, landblockY) + counter++;
}
} }

View file

@ -47,22 +47,29 @@ public static class LandblockLoader
// legacy starting-from-1 monotonic Ids — compatible with their assertions // legacy starting-from-1 monotonic Ids — compatible with their assertions
// which check uniqueness within a single landblock. // which check uniqueness within a single landblock.
// //
// Latent: if a landblock has >256 stabs (rare), nextId overflows the // The low byte reserves 0 for the namespace base and 1..255 for
// low byte and bleeds into the lbY byte → cross-LB collision. Same // entities. Never wrap into the Y byte: a collision here would corrupt
// pattern + same limitation as scenery/interior. Document but don't // every renderer/physics/effect table keyed by WorldEntity.Id.
// fix in this commit — out of scope for the Tier 1 cache bug fix.
uint stabIdBase = landblockId == 0 uint stabIdBase = landblockId == 0
? 0u ? 0u
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8; : 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u; 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) foreach (var stab in info.Objects)
{ {
if (!IsSupported(stab.Id)) if (!IsSupported(stab.Id))
continue; continue;
var stabEntity = new WorldEntity var stabEntity = new WorldEntity
{ {
Id = nextId++, Id = AllocateId(),
SourceGfxObjOrSetupId = stab.Id, SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin, Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation, Rotation = stab.Frame.Orientation,
@ -78,7 +85,7 @@ public static class LandblockLoader
continue; continue;
var buildingEntity = new WorldEntity var buildingEntity = new WorldEntity
{ {
Id = nextId++, Id = AllocateId(),
SourceGfxObjOrSetupId = building.ModelId, SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin, Position = building.Frame.Origin,
Rotation = building.Frame.Orientation, Rotation = building.Frame.Orientation,

View file

@ -0,0 +1,746 @@
using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectControllerTests
{
private const uint Guid = 0x70000001u;
private const uint DirectDid = 0x33000011u;
private const uint TypedDid = 0x33000022u;
private const uint TableDid = 0x34000033u;
private const uint RawType = 0x16u;
private sealed class NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) =>
Calls.Add((entityId, worldPosition, hook));
}
private sealed class Fixture
{
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
private readonly Dictionary<uint, PhysicsScriptTable> _tables = new();
private EntityEffectController? _controller;
public Fixture(
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Func<uint, PhysicsScriptTable?>? tableLoader = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
{
Spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
Spatial,
new NoopResources(),
record => _controller?.OnLiveEntityUnregistered(record));
Runner = new PhysicsScriptRunner(
id => _scripts.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
Router,
randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _controller?.CanAdvanceOwner(ownerId) ?? true);
Controller = new EntityEffectController(
Runtime,
Runner,
new PhysicsScriptTableResolver(
tableLoader
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
childAtPart,
parentOfAttachedChild,
ownerUnregistered,
ownerSoundTableChanged);
_controller = Controller;
Router.Register(Sink);
Router.Register(Controller);
AddScript(DirectDid, 1u);
AddScript(TypedDid, 2u);
_tables.Add(TableDid, BuildTable(TableDid, RawType, (1f, TypedDid)));
}
public GpuWorldState Spatial { get; } = new();
public LiveEntityRuntime Runtime { get; }
public RecordingSink Sink { get; } = new();
public AnimationHookRouter Router { get; } = new();
public PhysicsScriptRunner Runner { get; }
public EntityEffectController Controller { get; }
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = new CreateParticleHook { EmitterInfoId = emitterId },
});
_scripts[did] = script;
}
public void AddScript(uint did, AnimationHook hook, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = hook,
});
_scripts[did] = script;
}
public WorldEntity ReadyLive(
uint guid = Guid,
ushort generation = 1,
EntityEffectProfile? profile = null)
{
WorldSession.EntitySpawn spawn = Spawn(guid, generation);
Runtime.RegisterLiveEntity(spawn);
Runtime.SetEffectProfile(guid, profile ?? LiveProfile());
WorldEntity entity = Runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid))!;
Assert.True(Controller.OnLiveEntityReady(guid));
return entity;
}
public static EntityEffectProfile LiveProfile(
uint tableDid = TableDid,
uint rawDefaultType = RawType,
float defaultIntensity = 0.5f,
uint? soundTableDid = null) =>
EntityEffectProfile.CreateLive(
new Setup(),
default(PhysicsSpawnData) with
{
PhysicsScriptTableId = tableDid,
SoundTableId = soundTableDid,
DefaultScriptType = rawDefaultType,
DefaultScriptIntensity = defaultIntensity,
});
}
[Fact]
public void PreMaterializationDirectAndTypedPacketsReplayOnceInMixedOrder()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(2, fixture.Controller.PendingPacketCount);
WorldEntity entity = fixture.ReadyLive();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
// A duplicate readiness notification is idempotent and cannot replay.
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
}
[Fact]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{
var fixture = new Fixture();
fixture.ReadyLive();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void CelllessOwnerRejectsNewPacketAndPausesExistingQueueUntilReentry()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Guid,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
1,
2),
out _));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.ReportCollisions, 1, 3),
out _));
fixture.Runner.Tick(2.0);
Assert.Equal([1u, 1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PreCreatePacketWaitsForProjectionButPostCreateCelllessPacketIsDropped()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
WorldEntity entity = fixture.Runtime.MaterializeLiveEntity(
Guid,
0x02020001u,
id => Entity(id, Guid))!;
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Controller.RefreshLiveOwnerAnchors();
fixture.Runner.Tick(0.0);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
}
[Fact]
public void LoadedToPendingMovePausesActiveQueueUntilLandblockLoads()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x02020001u));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PendingInvalidTypedPacketDoesNotCorruptFollowingDirectPacket()
{
var fixture = new Fixture();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, float.NaN));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.ReadyLive();
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void PacketNeverUsesServerGuidOrFallbackAnchorBeforeOwnerIsReady()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(10.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(1, fixture.Controller.PendingPacketCount);
}
[Fact]
public void AcceptedDeleteClearsPendingGenerationBeforeGuidReuse()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn first = Spawn(Guid, generation: 1);
fixture.Runtime.RegisterLiveEntity(first);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
fixture.ReadyLive(Guid, generation: 2);
fixture.Runner.Tick(0.0);
Assert.Empty(fixture.Sink.Calls);
}
[Fact]
public void StaleDeleteCannotClearCurrentGenerationQueue()
{
var fixture = new Fixture();
WorldSession.EntitySpawn current = Spawn(Guid, generation: 2);
fixture.Runtime.RegisterLiveEntity(current);
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.False(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
current.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void UnknownDeleteClearsQueueThatNeverReceivedCreateObject()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ForgetUnknownOwner(Guid);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownStopsQueuedScriptsAndClearsReadyIdentity()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 60.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(0, fixture.Controller.ReadyOwnerCount);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownReleasesOwnerScopedResourcesBeforeGuidReuse()
{
var removedOwners = new List<uint>();
var soundTables = new DictionaryEntitySoundTable();
var fixture = new Fixture(
ownerUnregistered: ownerId =>
{
removedOwners.Add(ownerId);
soundTables.Remove(ownerId);
},
ownerSoundTableChanged: (ownerId, soundTableDid) =>
{
soundTables.Remove(ownerId);
if (soundTableDid is { } did)
soundTables.Set(ownerId, did);
});
uint firstLocalId = fixture.ReadyLive(
Guid,
generation: 1,
Fixture.LiveProfile(soundTableDid: 0x200000EEu)).Id;
Assert.Equal(0x200000EEu, soundTables.GetSoundTableId(firstLocalId));
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
uint replacementLocalId = fixture.ReadyLive(Guid, generation: 2).Id;
Assert.Equal([firstLocalId], removedOwners);
Assert.Equal(0u, soundTables.GetSoundTableId(firstLocalId));
Assert.Equal(0u, soundTables.GetSoundTableId(replacementLocalId));
Assert.NotEqual(firstLocalId, replacementLocalId);
}
[Fact]
public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup { DefaultSoundTable = 0x200000DDu };
EntityEffectProfile networkOverride = EntityEffectProfile.CreateLive(
setup,
default(PhysicsSpawnData) with { SoundTableId = 0x200000EEu });
WorldEntity first = fixture.ReadyLive(profile: networkOverride);
Assert.False(fixture.Runtime.TryGetAnimationRuntime(first.Id, out _));
Assert.Equal([(first.Id, (uint?)0x200000EEu)], changed);
changed.Clear();
EntityEffectProfile networkAbsent = EntityEffectProfile.CreateLive(
setup,
default);
WorldEntity second = fixture.ReadyLive(
guid: 0x70000002u,
profile: networkAbsent);
Assert.Equal([(second.Id, (uint?)null)], changed);
}
[Fact]
public void SameGenerationDescriptionRefreshReplacesAndClearsLiveSoundTable()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
EntityEffectProfile profile = Fixture.LiveProfile(
soundTableDid: 0x200000EEu);
WorldEntity entity = fixture.ReadyLive(profile: profile);
changed.Clear();
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0x200000EFu,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0u,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
Assert.Equal(
[
(entity.Id, (uint?)0x200000EFu),
(entity.Id, (uint?)null),
],
changed);
}
[Fact]
public void CallPesAndDefaultHooksEnqueueThroughOwningProfiles()
{
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(nestedDid, 4u);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new DefaultScriptHook());
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 4u], EmitterIds(fixture.Sink));
}
[Fact]
public void RunnerRouterControllerPathExecutesNestedCallPes()
{
const uint outerDid = 0x33000043u;
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(outerDid, new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.AddScript(nestedDid, 4u);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, outerDid));
fixture.Runner.Tick(0.0);
Assert.Collection(
fixture.Sink.Calls,
call => Assert.IsType<CallPESHook>(call.Hook),
call => Assert.Equal(4u, Assert.IsType<CreateParticleHook>(call.Hook).EmitterInfoId.DataId));
}
[Fact]
public void DefaultScriptPartResolvesAttachedChildAndUsesChildProfile()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture((parent, part) =>
parent == parentLocalId && part == 7u ? childLocalId : null);
parentLocalId = fixture.ReadyLive(Guid, generation: 1).Id;
childLocalId = fixture.ReadyLive(0x70000002u, generation: 1).Id;
fixture.Controller.OnHook(
parentLocalId,
Vector3.Zero,
new DefaultScriptPartHook { PartIndex = 7u });
fixture.Runner.Tick(0.0);
var call = Assert.Single(fixture.Sink.Calls);
Assert.Equal(childLocalId, call.EntityId);
Assert.Equal(2u, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId);
}
[Fact]
public void AttachedChildAdvancesThroughEligibleParentWhileCellless()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture(
parentOfAttachedChild: child => child == childLocalId ? parentLocalId : null);
parentLocalId = fixture.ReadyLive(Guid).Id;
childLocalId = fixture.ReadyLive(0x70000002u).Id;
fixture.Runtime.WithdrawLiveEntityProjection(0x70000002u);
Assert.True(fixture.Controller.PlayDefault(childLocalId));
fixture.Runner.Tick(0.0);
Assert.Equal(childLocalId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Runtime.WithdrawLiveEntityProjection(Guid);
Assert.False(fixture.Controller.PlayDefault(childLocalId));
}
[Fact]
public void MissingDirectScriptReportsOwnerAndDid()
{
const uint missingDid = 0x3300DEADu;
var fixture = new Fixture();
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, missingDid));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{missingDid:X8}", message, StringComparison.Ordinal);
}
[Fact]
public void TypedTableLoadFailureReportsOwnerTableAndException()
{
var fixture = new Fixture(
tableLoader: _ => throw new InvalidDataException("fixture corrupt table"));
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleTyped(
new PlayPhysicsScriptType(Guid, RawType, 0.5f));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{TableDid:X8}", message, StringComparison.Ordinal);
Assert.Contains(nameof(InvalidDataException), message, StringComparison.Ordinal);
Assert.Contains("fixture corrupt table", message, StringComparison.Ordinal);
}
[Fact]
public void StaticOwnerRetainsSetupTableAndIsRemovedSymmetrically()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup
{
DefaultScriptTable = TableDid,
DefaultSoundTable = 0x200000DDu,
};
WorldEntity entity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
entity.Id,
entity,
EntityEffectProfile.CreateDatStatic(setup));
Assert.Equal([(entity.Id, (uint?)0x200000DDu)], changed);
Assert.True(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
fixture.Runner.Tick(0.0);
Assert.Equal([2u], EmitterIds(fixture.Sink));
fixture.Controller.OnDatStaticEntityRemoved(entity.Id);
Assert.False(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
}
[Fact]
public void RegisteredSyntheticOwnerAdvancesThroughGlobalRunnerEligibility()
{
const uint skyOwnerId = 0xF0000042u;
var fixture = new Fixture();
fixture.Controller.RegisterSyntheticOwner(skyOwnerId);
Assert.True(fixture.Runner.PlayDirect(skyOwnerId, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal(skyOwnerId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Controller.UnregisterSyntheticOwner(skyOwnerId);
Assert.False(fixture.Controller.CanAdvanceOwner(skyOwnerId));
}
[Fact]
public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30));
fixture.Controller.RefreshLiveOwnerAnchors();
fixture.Runner.Tick(1.0);
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
}
[Fact]
public void ClearNetworkStatePreservesDatStaticProfiles()
{
var fixture = new Fixture();
fixture.ReadyLive();
WorldEntity staticEntity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
staticEntity.Id,
staticEntity,
EntityEffectProfile.CreateDatStatic(
new Setup { DefaultScriptTable = TableDid }));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ClearNetworkState();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
Assert.True(fixture.Controller.PlayTyped(staticEntity.Id, RawType, 0.5f));
}
private static PhysicsScriptTable BuildTable(
uint tableDid,
uint rawType,
params (float Mod, uint ScriptDid)[] entries)
{
var data = new PhysicsScriptTableData();
foreach ((float mod, uint scriptDid) in entries)
{
data.Scripts.Add(new ScriptAndModData
{
Mod = mod,
ScriptId = scriptDid,
});
}
var table = new PhysicsScriptTable { Id = tableDid };
table.ScriptTable.Add(unchecked((PlayScript)rawType), data);
return table;
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid, ushort generation)
{
var position = new CreateObject.ServerPosition(
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
1, 1, 1, 1, 0, 1, 0, 1, generation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: TableDid,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: RawType,
DefaultScriptIntensity: 0.5f,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: generation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static uint[] EmitterIds(RecordingSink sink) =>
sink.Calls
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
.ToArray();
}

View file

@ -15,6 +15,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid); Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid); Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000DDu, profile.CurrentSoundTableDid);
Assert.False(profile.HasNetworkDescription); Assert.False(profile.HasNetworkDescription);
} }
@ -27,6 +28,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid); Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Null(profile.CurrentPhysicsScriptTableDid); Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType); Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity); Assert.Equal(0f, profile.DefaultScriptIntensity);
Assert.True(profile.HasNetworkDescription); Assert.True(profile.HasNetworkDescription);
@ -38,6 +40,7 @@ public sealed class EntityEffectProfileTests
PhysicsSpawnData physics = default(PhysicsSpawnData) with PhysicsSpawnData physics = default(PhysicsSpawnData) with
{ {
PhysicsScriptTableId = 0x340000CCu, PhysicsScriptTableId = 0x340000CCu,
SoundTableId = 0x200000EEu,
DefaultScriptType = 0xA5A5A5A5u, DefaultScriptType = 0xA5A5A5A5u,
DefaultScriptIntensity = 0.75f, DefaultScriptIntensity = 0.75f,
}; };
@ -47,6 +50,7 @@ public sealed class EntityEffectProfileTests
physics); physics);
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid); Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000EEu, profile.CurrentSoundTableDid);
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType); Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
Assert.Equal(0.75f, profile.DefaultScriptIntensity); Assert.Equal(0.75f, profile.DefaultScriptIntensity);
} }
@ -66,11 +70,13 @@ public sealed class EntityEffectProfileTests
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{ {
PhysicsScriptTableId = 0u, PhysicsScriptTableId = 0u,
SoundTableId = 0u,
DefaultScriptType = 0u, DefaultScriptType = 0u,
DefaultScriptIntensity = 0f, DefaultScriptIntensity = 0f,
}); });
Assert.Null(profile.CurrentPhysicsScriptTableDid); Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType); Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity); Assert.Equal(0f, profile.DefaultScriptIntensity);
} }
@ -79,5 +85,6 @@ public sealed class EntityEffectProfileTests
{ {
DefaultScript = 0x330000AAu, DefaultScript = 0x330000AAu,
DefaultScriptTable = 0x340000BBu, DefaultScriptTable = 0x340000BBu,
DefaultSoundTable = 0x200000DDu,
}; };
} }

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
@ -8,7 +9,7 @@ using DatReaderWriter.Types;
using Xunit; using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Rendering.Vfx; namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityScriptActivatorTests public sealed class EntityScriptActivatorTests
{ {
@ -209,7 +210,7 @@ public sealed class EntityScriptActivatorTests
Assert.True(system.ActiveEmitterCount > 0, Assert.True(system.ActiveEmitterCount > 0,
"Setup precondition failed: emitter should be alive after the hook fires."); "Setup precondition failed: emitter should be alive after the hook fires.");
activator.OnRemove(0xCAFEu); activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount); Assert.Equal(0, runner.ActiveScriptCount);
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it. // sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
@ -218,7 +219,7 @@ public sealed class EntityScriptActivatorTests
} }
[Fact] [Fact]
public void OnCreate_KeysByEntityId_WhenServerGuidZero() public void OnCreate_UsesCanonicalStaticIdentity_WhenServerGuidZero()
{ {
// C.1.5b: dat-hydrated EnvCell statics + exterior stabs have // C.1.5b: dat-hydrated EnvCell statics + exterior stabs have
// ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range. // 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); Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f); p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls); 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); Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
} }
[Fact]
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
{
var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
activator.OnCreate(first);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => activator.OnCreate(second));
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
Assert.Equal(1, p.Runner.ActiveOwnerCount);
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
}
[Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var entity = new WorldEntity
{
Id = 1_000_123u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
}
[Fact] [Fact]
public void OnCreate_PassesPartTransformsToSink() public void OnCreate_PassesPartTransformsToSink()
{ {
@ -288,7 +328,7 @@ public sealed class EntityScriptActivatorTests
} }
[Fact] [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 // C.1.5b: caller passes the entity.Id as the key for dat-hydrated
// entities (not ServerGuid). OnRemove must clean up correctly. // entities (not ServerGuid). OnRemove must clean up correctly.
@ -318,48 +358,21 @@ public sealed class EntityScriptActivatorTests
runner.Tick(0.001f); runner.Tick(0.001f);
Assert.True(system.ActiveEmitterCount > 0); Assert.True(system.ActiveEmitterCount > 0);
activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount); Assert.Equal(0, runner.ActiveScriptCount);
system.Tick(0.01f); system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount); Assert.Equal(0, system.ActiveEmitterCount);
} }
[Fact] private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias()
{ {
const uint serverGuid = 0x7000CAFEu; Id = id,
const uint localId = 0x00100001u; ServerGuid = 0,
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u })); SourceGfxObjOrSetupId = 0x02000001u,
var p = BuildPipeline((0xAAu, script)); Position = position,
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
// This is the current AD-32 pre-materialization F754 path: no local ID
// exists yet, so the direct PES is temporarily owned by server GUID.
Assert.True(activator.PlayLegacyPending(serverGuid, 0xAAu, Vector3.Zero));
Assert.Equal(1, p.Runner.ActiveScriptCount);
Assert.Equal(1, activator.LegacyPendingOwnerCount);
activator.OnRemoveLegacyOwner(serverGuid, localId);
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
[Fact]
public void ClearLegacyPendingOwners_StopsF754AliasesWithoutLiveRecords()
{
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
var p = BuildPipeline((0xAAu, script));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
Assert.True(activator.PlayLegacyPending(0x70000001u, 0xAAu, Vector3.Zero));
Assert.True(activator.PlayLegacyPending(0x70000002u, 0xAAu, Vector3.Zero));
Assert.Equal(2, p.Runner.ActiveScriptCount);
Assert.Equal(2, activator.LegacyPendingOwnerCount);
activator.ClearLegacyPendingOwners();
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
} }

View file

@ -11,7 +11,7 @@ using DatReaderWriter.Types;
using Xunit; using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Streaming; namespace AcDream.App.Tests.Streaming;
/// <summary> /// <summary>
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires /// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
@ -94,7 +94,7 @@ public sealed class GpuWorldStateActivatorTests
// Tick fires the CreateParticleHook into RecordingSink. // Tick fires the CreateParticleHook into RecordingSink.
p.Runner.Tick(0.001f); p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls); 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); Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
} }

View file

@ -460,6 +460,46 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0, resources.UnregisterCount); 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] [Fact]
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity() public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
{ {
@ -701,109 +741,6 @@ public sealed class LiveEntityRuntimeTests
firstLocalEntityId: uint.MaxValue)); firstLocalEntityId: uint.MaxValue));
} }
[Fact]
public void Delete_StopsScriptsOwnedByCanonicalLocalId_NotServerGuid()
{
const uint serverGuid = 0x70000013u;
const uint scriptId = 0x33000001u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 60d,
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
});
var particleSystem = new ParticleSystem(new EmitterDescRegistry());
var particleSink = new ParticleHookSink(particleSystem);
var runner = new PhysicsScriptRunner(
id => id == scriptId ? script : null,
particleSink);
var activator = new EntityScriptActivator(
runner,
particleSink,
_ => new ScriptActivationInfo(scriptId, Array.Empty<System.Numerics.Matrix4x4>()));
var runtime = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(
activator.OnCreate,
entity => activator.OnRemove(entity.Id)),
record => activator.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u));
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 7, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
// Current AD-32 behavior before Step 4's pending FIFO: an F754 that
// precedes materialization temporarily starts under server GUID.
Assert.True(activator.PlayLegacyPending(
serverGuid,
scriptId,
System.Numerics.Vector3.Zero));
Assert.Equal(1, runner.ActiveScriptCount);
WorldEntity entity = runtime.MaterializeLiveEntity(
serverGuid,
spawn.Position!.Value.LandblockId,
id => Entity(id, serverGuid))!;
Assert.NotEqual(serverGuid, entity.Id);
Assert.Equal(2, runner.ActiveScriptCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, spawn.InstanceSequence),
isLocalPlayer: false));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void StaleDelete_DoesNotStopCurrentGenerationPreMaterializationF754Alias()
{
const uint serverGuid = 0x70000027u;
const uint scriptId = 0x33000001u;
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 60d,
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
});
var particleSink = new ParticleHookSink(
new ParticleSystem(new EmitterDescRegistry()));
var runner = new PhysicsScriptRunner(
id => id == scriptId ? script : null,
particleSink);
var activator = new EntityScriptActivator(
runner,
particleSink,
_ => null);
var runtime = new LiveEntityRuntime(
new GpuWorldState(),
new RecordingResources(),
record => activator.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u));
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 10, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
Assert.True(activator.PlayLegacyPending(
serverGuid,
scriptId,
System.Numerics.Vector3.Zero));
Assert.False(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, 9),
isLocalPlayer: false));
Assert.Equal(1, runner.ActiveScriptCount);
Assert.Equal(1, activator.LegacyPendingOwnerCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, 10),
isLocalPlayer: false));
Assert.Equal(0, runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
[Fact] [Fact]
public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails() public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails()
{ {

View file

@ -18,9 +18,15 @@ public sealed class RetailDatLoaderTests
private sealed class RawDatabase : IDatDatabase private sealed class RawDatabase : IDatDatabase
{ {
private readonly Dictionary<uint, byte[]> _entries = new(); private readonly Dictionary<uint, byte[]> _entries = new();
private int _activeReads;
private int _maxConcurrentReads;
private int _totalReads;
public DatDatabase Db => null!; public DatDatabase Db => null!;
public int Iteration => 0; 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 void Add(uint id, byte[] bytes) => _entries[id] = bytes;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys; public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
@ -28,8 +34,28 @@ public sealed class RetailDatLoaderTests
value = default; value = default;
return false; return false;
} }
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) => public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
_entries.TryGetValue(fileId, out 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) public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
{ {
if (!_entries.TryGetValue(fileId, out byte[]? value)) if (!_entries.TryGetValue(fileId, out byte[]? value))
@ -176,6 +202,47 @@ public sealed class RetailDatLoaderTests
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey)); Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
} }
[Fact]
public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads()
{
const uint firstDid = 0x33010010u;
const uint secondDid = 0x33010011u;
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
byte[] first = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
byte[] second = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(first, firstDid);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(second, secondDid);
portal.Add(firstDid, first);
portal.Add(secondDid, second);
var loader = new RetailPhysicsScriptLoader(portal);
await Task.WhenAll(
Task.Run(() => loader.LoadPhysicsScript(firstDid)),
Task.Run(() => loader.LoadPhysicsScript(secondDid)));
Assert.Equal(2, portal.MaxConcurrentReads);
Assert.Equal(2, portal.TotalReads);
}
[Fact]
public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid()
{
const uint scriptDid = 0x33010012u;
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(bytes, scriptDid);
portal.Add(scriptDid, bytes);
var loader = new RetailPhysicsScriptLoader(portal);
DatPhysicsScript?[] loaded = await Task.WhenAll(
Task.Run(() => loader.LoadPhysicsScript(scriptDid)),
Task.Run(() => loader.LoadPhysicsScript(scriptDid)));
Assert.All(loaded, Assert.NotNull);
Assert.Same(loaded[0], loaded[1]);
Assert.Equal(1, portal.TotalReads);
}
[Fact] [Fact]
public void Parsers_RejectTrailingOrTruncatedEntries() public void Parsers_RejectTrailingOrTruncatedEntries()
{ {
@ -192,6 +259,17 @@ public sealed class RetailDatLoaderTests
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray())); RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
} }
[Fact]
public void PhysicsScriptParserRejectsNonFiniteHookTime()
{
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(
script.AsSpan(8, 8),
BitConverter.DoubleToInt64Bits(double.NaN));
Assert.Throws<InvalidDataException>(() => RetailPhysicsScriptLoader.Parse(script));
}
[Fact] [Fact]
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects() public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
{ {

View file

@ -1,234 +1,517 @@
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Vfx; namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptRunnerTests public sealed class PhysicsScriptRunnerTests
{ {
/// <summary>
/// Recording sink so tests can assert each hook dispatch.
/// </summary>
private sealed class RecordingSink : IAnimationHookSink private sealed class RecordingSink : IAnimationHookSink
{ {
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook)); public Action<uint, Vector3, AnimationHook>? Callback { get; init; }
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
{
Calls.Add((entityId, worldPosition, hook));
Callback?.Invoke(entityId, worldPosition, hook);
}
} }
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items) private static DatPhysicsScript BuildScript(params (double Time, AnimationHook Hook)[] items)
{ {
var script = new DatPhysicsScript(); var script = new DatPhysicsScript();
foreach (var (t, h) in items) foreach ((double time, AnimationHook hook) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h }); script.ScriptData.Add(new PhysicsScriptData { StartTime = time, Hook = hook });
return script; return script;
} }
private static CreateParticleHook CreateHook(uint emitterInfoId) private static CreateParticleHook CreateHook(uint emitterInfoId) =>
=> new CreateParticleHook { EmitterInfoId = emitterInfoId }; new() { EmitterInfoId = emitterInfoId };
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts) private static PhysicsScriptRunner MakeRunner(
IAnimationHookSink sink,
Func<double>? clock = null,
Func<double>? randomUnit = null,
Func<uint, bool>? canAdvanceOwner = null,
params (uint Id, DatPhysicsScript Script)[] scripts)
{ {
// Build an in-memory resolver from the script table — no DatCollection needed. var table = scripts.ToDictionary(entry => entry.Id, entry => entry.Script);
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
return new PhysicsScriptRunner( return new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
sink); sink,
clock,
randomUnit,
canAdvanceOwner);
} }
[Fact] [Fact]
public void Play_UnknownScript_ReturnsFalse() public void PlayDirect_MissingOrZeroInputReturnsFalseWithoutQueueCorruption()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var runner = MakeRunner(sink); // no scripts registered var runner = MakeRunner(sink);
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
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); Assert.Empty(sink.Calls);
} }
[Fact] [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 sink = new RecordingSink();
var runner = MakeRunner(sink); var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero)); 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); Assert.Equal(0, runner.ActiveScriptCount);
} }
[Fact] [Fact]
public void HooksFire_InOrder_AtScheduledTimes() public void DuplicatePlaysAppendSeriallyToOneOwner()
{ {
var script = BuildScript( DatPhysicsScript 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(
(0.0, CreateHook(1)), (0.0, CreateHook(1)),
(1.0, CreateHook(2))); (1.0, CreateHook(2)));
var sink = new RecordingSink(); 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); Assert.True(runner.PlayDirect(1u, 0x330000AAu));
runner.Tick(0.1f); Assert.True(runner.PlayDirect(1u, 0x330000AAu));
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.Equal(2, runner.ActiveScriptCount); Assert.Equal(2, runner.ActiveScriptCount);
Assert.Equal(1, runner.ActiveOwnerCount);
runner.Tick(0.1f); runner.Tick(0.0);
Assert.Equal(2, sink.Calls.Count); Assert.Equal([1u], EmitterIds(sink));
Assert.Contains(sink.Calls, c => c.EntityId == 1u);
Assert.Contains(sink.Calls, c => c.EntityId == 2u); // 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] [Fact]
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers() public void DifferentOwnersProgressIndependently()
{ {
var script = BuildScript( DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)), (0.0, CreateHook(1)),
(1.0, CreateHook(2))); (1.0, CreateHook(2)));
var sink = new RecordingSink(); 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.PlayDirect(1u, 0x330000AAu);
runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero); runner.Tick(0.5);
runner.Tick(0.1f); // both fire hook 0 runner.PlayDirect(2u, 0x330000AAu);
Assert.Equal(2, sink.Calls.Count); 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); Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(2.0f); // only entity 2's script should fire hook 1 Assert.Equal(1, runner.ActiveOwnerCount);
Assert.Equal(3, sink.Calls.Count);
Assert.Equal(2u, sink.Calls[^1].EntityId);
} }
[Fact] [Fact]
public void CallPES_NestedScript_SpawnsOnSameEntity() public void CatchUpTickDrainsEveryDueHookAndQueuedScript()
{ {
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f })); DatPhysicsScript script = BuildScript(
var inner = BuildScript((0.0, CreateHook(99))); (0.0, CreateHook(1)),
(0.25, CreateHook(2)),
(0.5, CreateHook(3)));
var sink = new RecordingSink(); var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner)); var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3)); runner.PlayDirect(1u, 0x330000AAu);
runner.PlayDirect(1u, 0x330000AAu);
// First tick fires the CallPES hook. Inner script gets queued to runner.Tick(5.0);
// _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
// Second tick — inner's hook at t=0 fires now. Assert.Equal([1u, 2u, 3u, 1u, 2u, 3u], EmitterIds(sink));
runner.Tick(0.1f); Assert.Equal(0, runner.ActiveScriptCount);
Assert.Single(sink.Calls);
Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
Assert.Equal(0x7u, sink.Calls[0].EntityId);
} }
[Fact] [Fact]
public void CallPES_WithPause_DelaysSubScript() public void HookMayAppendToCurrentOwnerWithoutInvalidatingCatchUp()
{ {
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f })); DatPhysicsScript outer = BuildScript((0.0, CreateHook(1)));
var inner = BuildScript((0.0, CreateHook(99))); 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(); runner.Tick(0.0);
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
// CallPES fires immediately, but inner script's hook is gated by Pause. Assert.Equal([1u, 2u], EmitterIds(sink));
runner.Tick(0.1f); Assert.Equal(0, runner.ActiveScriptCount);
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);
} }
[Fact] [Fact]
public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance() public void HookMayDeleteItsOwnerWithoutDispatchingDetachedTail()
{ {
var script = BuildScript( DatPhysicsScript script = BuildScript(
(0.0, new CallPESHook { PES = 0xAA, Pause = 30f }), (0.0, CreateHook(1)),
(0.0, CreateHook(123))); (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 sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script)); var runner = MakeRunner(
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); 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.True(runner.ScheduleCallPes(
Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); 1u,
0x330000AAu,
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
Assert.Equal(1, runner.ActiveScriptCount); Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(1.0);
runner.Tick(29.8f); Assert.Equal([9u, 9u], EmitterIds(sink));
Assert.Single(sink.Calls);
runner.Tick(0.3f);
Assert.Equal(2, sink.Calls.Count);
} }
[Fact]
public void PublishedAnimationPhaseRunsImmediateScriptButDefersTimedCallOnePass()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.0,
scripts: [(0x330000AAu, script)]);
runner.PublishTime(1.0);
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 0.5f));
Assert.True(runner.ScheduleCallPes(
2u,
0x330000AAu,
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
runner.Tick(1.0);
Assert.Equal([(2u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
runner.PublishTime(1.1);
runner.Tick(1.1);
Assert.Equal([(2u, 9u), (1u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
}
[Fact]
public void UpdatePhasePlayUsesPublishedCurrentClockForPositiveHookOffset()
{
DatPhysicsScript script = BuildScript((0.1, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PublishTime(1.0);
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
runner.Tick(1.0);
Assert.Empty(sink.Calls);
runner.PublishTime(1.1);
runner.Tick(1.1);
Assert.Equal([9u], EmitterIds(sink));
}
[Fact]
public void IneligibleOwnerRetainsScriptsAndTimedCallsUntilReentry()
{
bool eligible = true;
DatPhysicsScript script = BuildScript((1.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.5,
canAdvanceOwner: _ => eligible,
scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.ScheduleCallPes(2u, 0x330000AAu, 2f);
eligible = false;
runner.Tick(2.0);
Assert.Empty(sink.Calls);
Assert.Equal(1, runner.ActiveScriptCount);
Assert.Equal(1, runner.ScheduledCallPesCount);
eligible = true;
runner.Tick(2.0);
Assert.Equal([(1u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
runner.Tick(3.0);
Assert.Equal([(1u, 9u), (2u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
}
[Fact]
public void ZeroTimeRecursiveCallChainIsRejectedWithoutHanging()
{
const uint firstDid = 0x330000AAu;
const uint secondDid = 0x330000BBu;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
? secondDid
: firstDid;
runner!.PlayDirect(owner, target);
},
};
runner = MakeRunner(
sink,
scripts:
[
(firstDid, BuildScript((0.0, CreateHook(1)))),
(secondDid, BuildScript((0.0, CreateHook(2)))),
]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
runner.PlayDirect(1u, firstDid);
runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(sink));
Assert.Single(diagnostics, message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void PositiveDurationRecursiveChainDrainsCatchUpWithoutFalseCycleRejection()
{
const uint firstDid = 0x33000428u;
const uint secondDid = 0x33000429u;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
? secondDid
: firstDid;
runner!.PlayDirect(owner, target);
},
};
runner = MakeRunner(
sink,
scripts:
[
(firstDid, BuildScript((2.8, CreateHook(1)))),
(secondDid, BuildScript((2.8, CreateHook(2)))),
]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
runner.PlayDirect(1u, firstDid);
runner.Tick(8.4);
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
Assert.DoesNotContain(
diagnostics,
message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
Assert.Equal(1, runner.ActiveScriptCount);
}
[Fact]
public void CrossOwnerReentrantPlayWaitsForNextGlobalScriptPass()
{
const uint parentDid = 0x330000AAu;
const uint childCurrentDid = 0x330000BBu;
const uint childDefaultDid = 0x330000CCu;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (_, _, hook) =>
{
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
runner!.PlayDirect(2u, childDefaultDid);
},
};
runner = MakeRunner(
sink,
scripts:
[
(parentDid, BuildScript((0.0, CreateHook(1)))),
(childCurrentDid, BuildScript((0.0, CreateHook(2)))),
(childDefaultDid, BuildScript((0.0, CreateHook(3)))),
]);
runner.PlayDirect(1u, parentDid);
runner.PlayDirect(2u, childCurrentDid);
runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(sink));
runner.Tick(0.0);
Assert.Equal([1u, 2u, 3u], EmitterIds(sink));
}
[Fact]
public void NonFiniteTimelineIsRejectedBeforeItCanBypassRecursionGuard()
{
const uint scriptDid = 0x330000AAu;
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
scripts: [(scriptDid, BuildScript((double.NaN, CreateHook(1))))]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
Assert.False(runner.PlayDirect(1u, scriptDid));
runner.Tick(0.0);
Assert.Empty(sink.Calls);
Assert.Contains(diagnostics, message => message.Contains("non-finite", StringComparison.Ordinal));
}
[Fact]
public void ScheduleCallPesRejectsNonFiniteInputsAndStopClearsDelayedCalls()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.5,
scripts: [(0x330000AAu, script)]);
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.NaN));
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.PositiveInfinity));
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
runner.StopAllForEntity(1u);
runner.Tick(10.0);
Assert.Equal(0, runner.ScheduledCallPesCount);
Assert.Empty(sink.Calls);
}
[Fact]
public void CurrentAnchorIsReadWhenHookFires()
{
DatPhysicsScript script = BuildScript((1.0, CreateHook(1)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.SetOwnerAnchor(1u, new Vector3(1, 0, 0));
runner.PlayDirect(1u, 0x330000AAu);
runner.SetOwnerAnchor(1u, new Vector3(7, 8, 9));
runner.Tick(1.0);
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(sink.Calls).Position);
}
[Fact]
public void EveryHookFansOutThroughCompleteRouter()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(1)));
var first = new RecordingSink();
var second = new RecordingSink();
var router = new AnimationHookRouter();
router.Register(first);
router.Register(second);
var runner = MakeRunner(router, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(0.0);
Assert.Single(first.Calls);
Assert.Single(second.Calls);
}
[Fact]
public void LoaderFailureIsCachedAndDoesNotCorruptOtherOwners()
{
DatPhysicsScript good = BuildScript((0.0, CreateHook(1)));
int failingLoads = 0;
var sink = new RecordingSink();
var runner = new PhysicsScriptRunner(
id =>
{
if (id == 0x330000AAu)
return good;
failingLoads++;
throw new InvalidDataException("fixture DAT failure");
},
sink);
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
Assert.True(runner.PlayDirect(2u, 0x330000AAu));
runner.Tick(0.0);
Assert.Equal(1, failingLoads);
Assert.Equal([1u], EmitterIds(sink));
}
[Fact]
public void TickRejectsNonFiniteOrBackwardsTime()
{
var runner = MakeRunner(new RecordingSink());
runner.Tick(1.0);
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(0.5));
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.NaN));
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.PositiveInfinity));
}
private static uint[] EmitterIds(RecordingSink sink) =>
sink.Calls
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
.ToArray();
} }

View file

@ -97,6 +97,17 @@ public sealed class PhysicsScriptTableResolverTests
Assert.Null(resolver.Resolve(TableDid, RawType, 0f)); Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
} }
[Fact]
public void Resolve_LoaderFailureReturnsNoPlayAndPreservesDiagnosticException()
{
var resolver = new PhysicsScriptTableResolver(
_ => throw new InvalidDataException("fixture DAT failure"));
Assert.Null(resolver.Resolve(TableDid, RawType, 0f, out Exception? failure));
var invalid = Assert.IsType<InvalidDataException>(failure);
Assert.Equal("fixture DAT failure", invalid.Message);
}
private static PhysicsScriptTable BuildTable( private static PhysicsScriptTable BuildTable(
params (float Mod, uint ScriptDid)[] entries) params (float Mod, uint ScriptDid)[] entries)
{ {

View file

@ -87,4 +87,20 @@ public class InteriorEntityIdAllocatorTests
// that accidentally narrows it again gets caught here first. // that accidentally narrows it again gets caught here first.
Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter); Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter);
} }
[Fact]
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
{
uint counter = InteriorEntityIdAllocator.MaxCounter;
uint last = InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter);
Assert.Equal(
InteriorEntityIdAllocator.Base(0xA9u, 0xB4u)
+ InteriorEntityIdAllocator.MaxCounter,
last);
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter));
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
}
} }

View file

@ -163,6 +163,19 @@ public class LandblockLoaderTests
Assert.Equal(1u, entities[0].Id); Assert.Equal(1u, entities[0].Id);
} }
[Fact]
public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias()
{
var info = new LandBlockInfo();
for (uint i = 0; i < 256u; i++)
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u));
Assert.Contains("255-entry", error.Message, StringComparison.Ordinal);
}
[Fact] [Fact]
public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue() public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue()
{ {