feat(vfx): decode retail hooks and typed tables

Replace the incomplete package path with one DatCollection-backed compatibility seam for PhysicsScripts and Animations. Preserve CreateBlockingParticle's inherited payload and following cursor, route every production and audit consumer through the corrected loaders, and apply retail's post-UnPack StartTime ordering.

Add exact stored-order PhysicsScriptTable upper-threshold resolution, high-byte DID and embedded-ID validation, plus live effect profiles with Setup-to-PhysicsDesc precedence across top-level and attached entity lifetimes. Keep blocking execution deferred and narrow TS-11 accordingly.

Pin synthetic malformed/cursor/order fixtures, installed-DAT blocking and recall audits, high-index IDs, IEEE boundaries, profile teardown, and ordinary decoder parity; synchronize architecture, inventory, milestones, roadmap, research, and memory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 08:17:44 +02:00
parent 8dd996053d
commit 363e046112
31 changed files with 1102 additions and 73 deletions

View file

@ -258,7 +258,9 @@ Ownership by phase:
`LiveEntityRuntime` is now the shipped bridge to this target. It owns one `LiveEntityRuntime` is now the shipped bridge to this target. It owns one
`LiveEntityRecord` per accepted server-object incarnation, ServerGuid-to-local-id `LiveEntityRecord` per accepted server-object incarnation, ServerGuid-to-local-id
translation, accepted snapshots/timestamp gates, animation and remote-motion translation, accepted snapshots/timestamp gates, animation and remote-motion
components, parent-event state, and exact logical teardown. `GpuWorldState` components, parent-event state, effect-profile defaults, and exact logical
teardown. Live `PhysicsDesc` effect fields replace Setup defaults on that same
record; rebucketing never recreates them. `GpuWorldState`
owns spatial buckets only: register/rebucket/unregister are separate operations, owns spatial buckets only: register/rebucket/unregister are separate operations,
and landblock reloads reuse the same `WorldEntity` without replaying renderer or and landblock reloads reuse the same `WorldEntity` without replaying renderer or
script creation. Its canonical materialized view remains stable across pending script creation. Its canonical materialized view remains stable across pending
@ -267,6 +269,14 @@ targeting. Pickup/parent leave-world clears cell membership and pauses root
movement/animation without destroying retained owners. `GameWindow` retains movement/animation without destroying retained owners. `GameWindow` retains
storage-free typed views while its large feature loops are extracted. storage-free typed views while its large feature loops are extracted.
DAT decoding for runtime VFX crosses one narrow `AcDream.Content.Vfx` seam.
`RetailAnimationLoader` and `RetailPhysicsScriptLoader` still read through the
single shared `DatCollection`, delegate ordinary animation-hook schemas to
Chorizite.DatReaderWriter, and replace only its incomplete
`CreateBlockingParticleHook` model with retail's inherited CreateParticle
payload. Core consumes the loaders through delegates/interfaces and therefore
does not depend on Content or a database implementation.
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

@ -230,7 +230,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | | TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) | | TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.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-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 | `CreateBlockingParticleHook` is consumed as a no-op because the package model omits its inherited CreateParticle payload; acdream therefore also lacks retail's same-live-nonzero-logical-ID suppression | `src/AcDream.Core/Vfx/ParticleHookSink.cs:112` | A narrow raw-hook compatibility reader is required so the shared payload is decoded without replacing `DatCollection` or duplicating ordinary hook schemas | Blocking-particle hooks emit nothing today; without the corrected decoder the next hook can also be cursor-misaligned, and repeated logical IDs cannot follow retail replacement/suppression rules | `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-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 |

View file

@ -101,6 +101,18 @@ behavior change, no divergence-register row.
running with no GL context, so baked pak output and live-client output stay running with no GL context, so baked pak output and live-client output stay
byte-identical. byte-identical.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in
`src/AcDream.Content/Vfx/` read raw bytes through the existing `DatCollection`
database, delegate every ordinary hook to the package, and substitute only the
retail blocking-particle shape. Both live animation playback and PhysicsScript
loading use those cached readers; `MeshExtractor` uses the same PhysicsScript
loader when preloading emitter meshes. This is not a second DAT reader:
`DatCollection` remains the sole database owner and access path. Retail anchors:
`CreateBlockingParticleHook::Execute` `0x00526EF0` and
`ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling **Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢 algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has

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 02 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; AP-69 is narrowed to the missing retail liveness cull and AD-32 to the remaining mixed pre-create queue. - **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.
- **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,15 +471,18 @@ 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 02 - **M2/M3 missile, effect, and portal presentation campaign (Steps 03
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
`LiveEntityRuntime` now separates logical lifetime from spatial rebucketing. `LiveEntityRuntime` now separates logical lifetime from spatial rebucketing.
Landblock churn and equipment attachment preserve one stable local entity and Landblock churn and equipment attachment preserve one stable local entity and
cannot replay create-time scripts. The remaining steps port DAT hook decoding, cannot replay create-time scripts. A narrow raw-hook seam now decodes retail's
per-owner effect scheduling, live animated attachment poses, projectile motion inherited `CreateBlockingParticle` payload for both PhysicsScripts and
and sweeps, and Hidden/UnHide portal presentation. Animations, while exact stored-order `PhysicsScriptTable` threshold lookup and
Setup-versus-network default precedence are pinned by conformance tests. The
remaining steps port per-owner effect scheduling, live animated attachment
poses, projectile motion and sweeps, and Hidden/UnHide portal presentation.
- **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

@ -339,6 +339,11 @@ Oracles:
- `ScriptManager::NextHook` at `0x0051B3F0` - `ScriptManager::NextHook` at `0x0051B3F0`
- `ScriptManager::UpdateScripts` at `0x0051B480` - `ScriptManager::UpdateScripts` at `0x0051B480`
`PhysicsScript::UnPack` at `0x005218B0` first sorts its decoded hook records
by ascending `StartTime` with `PhysicsScriptData::Sort` at `0x00521600`.
This sort applies to PhysicsScript hook time, not to PhysicsScriptTable `Mod`
thresholds (which must remain in stored DAT order).
```text ```text
add(script): add(script):
node = new ScriptData node = new ScriptData
@ -661,11 +666,13 @@ noncopyrighted fixtures:
- `tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs`: - `tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs`:
exact F754/F755, a broad projectile-field CreateObject, isolated Movement exact F754/F755, a broad projectile-field CreateObject, isolated Movement
and AnimationFrame branches, and absent-versus-present-zero PeTable shapes. and AnimationFrame branches, and absent-versus-present-zero PeTable shapes.
Step 1 adds truncation at each gated cursor. Step 1 tests include truncation at each gated cursor.
- `tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs`: a synthetic raw - `tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs`: a synthetic raw
PhysicsScript and Animation container, each containing a complete PhysicsScript and Animation container, each containing a complete
CreateBlocking payload followed by an AnimationDone hook at the exact CreateBlocking payload followed by an AnimationDone hook at the exact
expected cursor. expected cursor. `RetailDatLoaderTests` verifies both corrected containers,
ordinary-hook parity with the package decoder, malformed-entry rejection,
and representative installed blocking scripts.
Representative installed raw-entry identities are pinned as metadata, not Representative installed raw-entry identities are pinned as metadata, not
copied bytes: copied bytes:

View file

@ -230,6 +230,18 @@ public sealed class EquippedChildRenderController : IDisposable
out IReadOnlyList<MeshRef> parts)) out IReadOnlyList<MeshRef> parts))
return; return;
// A parented object may materialize here before the ordinary world
// hydration path sees it. Install its logical effect profile before
// MaterializeLiveEntity registers runtime resources, exactly as for a
// top-level projection; rebucketing/reattachment must never recreate it.
if (!_liveEntities.TryGetEffectProfile(childGuid, out _))
{
var effectProfile = childSpawn.Physics is { } physics
? Vfx.EntityEffectProfile.CreateLive(childSetup, physics)
: Vfx.EntityEffectProfile.CreateDatStatic(childSetup);
_liveEntities.SetEffectProfile(childGuid, effectProfile);
}
Remove(childGuid); Remove(childGuid);
WorldEntity? entity = _liveEntities.MaterializeLiveEntity( WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid, childGuid,

View file

@ -313,7 +313,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.WorldEntity Entity, AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation); AnimatedEntity? Animation);
private AcDream.Core.Physics.DatCollectionLoader? _animLoader; private AcDream.Core.Physics.IAnimationLoader? _animLoader;
// Phase E.1: central fan-out for animation hooks. Audio (E.2), // Phase E.1: central fan-out for animation hooks. Audio (E.2),
// particles (E.3), combat (E.4), and renderer state mutators all // particles (E.3), combat (E.4), and renderer state mutators all
@ -336,6 +336,7 @@ public sealed class GameWindow : IDisposable
// from the server and schedules the dat-defined hooks (particle spawns, // from the server and schedules the dat-defined hooks (particle spawns,
// sounds, light toggles) at their StartTime offsets. // sounds, light toggles) at their StartTime offsets.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
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.
@ -1432,7 +1433,7 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged; _cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read); _dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); _animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
// Phase E.3 particles: always-on, no driver dependency. Registered // Phase E.3 particles: always-on, no driver dependency. Registered
@ -1447,7 +1448,10 @@ public sealed class GameWindow : IDisposable
// resolve PlayScript ids, and the same ParticleHookSink the // resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a // animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline. // script spawns through the normal particle pipeline.
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(_dats, _particleSink); _physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_particleSink);
// 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,
@ -3319,7 +3323,7 @@ public sealed class GameWindow : IDisposable
// animated entry registered) — this happens for entities the // animated entry registered) — this happens for entities the
// resolver short-circuits on. // resolver short-circuits on.
var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
setup, _dats, setup, _dats, _animLoader!,
motionTableIdOverride: spawn.MotionTableId, motionTableIdOverride: spawn.MotionTableId,
stanceOverride: stanceOverride, stanceOverride: stanceOverride,
commandOverride: commandOverride); commandOverride: commandOverride);
@ -3688,6 +3692,14 @@ public sealed class GameWindow : IDisposable
return; return;
} }
if (!_liveEntities!.TryGetEffectProfile(spawn.Guid, out _))
{
var effectProfile = spawn.Physics is { } physics
? AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateLive(setup, physics)
: AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
_liveEntities.SetEffectProfile(spawn.Guid, effectProfile);
}
bool createdProjection = false; bool createdProjection = false;
var entity = _liveEntities!.MaterializeLiveEntity( var entity = _liveEntities!.MaterializeLiveEntity(
spawn.Guid, spawn.Guid,
@ -4169,7 +4181,7 @@ public sealed class GameWindow : IDisposable
// garbage frame count → OOM). // garbage frame count → OOM).
uint poseDid = ResolvePaperdollPoseDid(); uint poseDid = ResolvePaperdollPoseDid();
if ((poseDid >> 24) != 0x03u) return; if ((poseDid >> 24) != 0x03u) return;
var anim = dats.Get<DatReaderWriter.DBObjs.Animation>(poseDid); var anim = _animLoader?.LoadAnimation(poseDid);
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId); var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
if (anim is null || setup is null || anim.PartFrames.Count == 0) return; if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
@ -4361,6 +4373,14 @@ public sealed class GameWindow : IDisposable
private void RouteSameGenerationCreateObject( private void RouteSameGenerationCreateObject(
AcDream.App.World.SameGenerationCreateObjectEvents refresh) AcDream.App.World.SameGenerationCreateObjectEvents refresh)
{ {
if (_liveEntities!.TryGetEffectProfile(
refresh.Appearance.Guid,
out var effectProfile)
&& effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
{
liveProfile.ApplyNetworkDescription(refresh.Description);
}
OnLiveAppearanceUpdated(refresh.Appearance); OnLiveAppearanceUpdated(refresh.Appearance);
if (refresh.Parent is { } parent if (refresh.Parent is { } parent
@ -4481,7 +4501,7 @@ public sealed class GameWindow : IDisposable
if (mt is null) return null; if (mt is null) return null;
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
mt, id => _dats.Get<DatReaderWriter.DBObjs.Animation>(id)); mt, id => _animLoader?.LoadAnimation(id));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{ {
@ -5464,7 +5484,7 @@ public sealed class GameWindow : IDisposable
// a concrete Animation + frame range. Only swap when the resolver // a concrete Animation + frame range. Only swap when the resolver
// returns a clearly-better cycle. // returns a clearly-better cycle.
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats, ae.Setup, _dats, _animLoader!,
motionTableIdOverride: null, motionTableIdOverride: null,
stanceOverride: stance, stanceOverride: stance,
commandOverride: command); commandOverride: command);

View file

@ -135,7 +135,7 @@ public static class RenderBootstrap
var textureCache = new TextureCache(gl, dats, bindless); var textureCache = new TextureCache(gl, dats, bindless);
// --- AnimLoader (GameWindow ~1240) --- // --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Core.Physics.DatCollectionLoader(dats); var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) --- // --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance; var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;

View file

@ -0,0 +1,73 @@
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// DAT defaults and the currently installed network effect description for
/// one logical entity incarnation.
/// </summary>
/// <remarks>
/// Retail precedence comes from <c>CPhysicsObj::InitDefaults</c>
/// (<c>0x005139D0</c>) followed by <c>CPhysicsObj::set_description</c>
/// (<c>0x00514F40</c>). Setup's direct default PES runs during initialization;
/// a network PhysicsDesc then unconditionally replaces the typed table, even
/// when PeTable was absent or zero.
/// </remarks>
public sealed class EntityEffectProfile : ILiveEntityEffectProfile
{
private EntityEffectProfile(Setup setup)
{
SetupDefaultScriptDid = NormalizePhysicsScriptDid(setup.DefaultScript.DataId);
CurrentPhysicsScriptTableDid = NormalizeTableDid((uint)setup.DefaultScriptTable);
}
public uint? SetupDefaultScriptDid { get; }
public uint? CurrentPhysicsScriptTableDid { get; private set; }
public uint RawDefaultScriptType { get; private set; }
public float DefaultScriptIntensity { get; private set; }
public bool HasNetworkDescription { get; private set; }
/// <summary>Setup defaults remain active for a DAT/static object.</summary>
public static EntityEffectProfile CreateDatStatic(Setup setup)
{
ArgumentNullException.ThrowIfNull(setup);
return new EntityEffectProfile(setup);
}
/// <summary>
/// Applies Setup initialization followed by the live PhysicsDesc, matching
/// retail construction order.
/// </summary>
public static EntityEffectProfile CreateLive(
Setup setup,
PhysicsSpawnData physics)
{
ArgumentNullException.ThrowIfNull(setup);
var profile = new EntityEffectProfile(setup);
profile.ApplyNetworkDescription(physics);
return profile;
}
/// <summary>
/// Replaces all network-owned effect defaults. Nullable wire fields use
/// PhysicsDesc's initialized zero values; they never resurrect Setup's
/// typed table.
/// </summary>
public void ApplyNetworkDescription(PhysicsSpawnData physics)
{
CurrentPhysicsScriptTableDid = NormalizeTableDid(
physics.PhysicsScriptTableId.GetValueOrDefault());
RawDefaultScriptType = physics.DefaultScriptType.GetValueOrDefault();
DefaultScriptIntensity = physics.DefaultScriptIntensity.GetValueOrDefault();
HasNetworkDescription = true;
}
private static uint? NormalizePhysicsScriptDid(uint did) =>
PhysicsScriptTableResolver.IsPhysicsScriptDid(did) ? did : null;
private static uint? NormalizeTableDid(uint did) =>
PhysicsScriptTableResolver.IsPhysicsScriptTableDid(did) ? did : null;
}

View file

@ -542,6 +542,7 @@ public sealed class InboundPhysicsStateController
: null; : null;
return new SameGenerationCreateObjectEvents( return new SameGenerationCreateObjectEvents(
physics,
new ObjDescEvent.Parsed( new ObjDescEvent.Parsed(
incoming.Guid, incoming.Guid,
new CreateObject.ModelData( new CreateObject.ModelData(
@ -632,6 +633,7 @@ public readonly record struct CreateParentUpdate(
ushort ChildPositionSequence); ushort ChildPositionSequence);
public readonly record struct SameGenerationCreateObjectEvents( public readonly record struct SameGenerationCreateObjectEvents(
PhysicsSpawnData Description,
ObjDescEvent.Parsed Appearance, ObjDescEvent.Parsed Appearance,
CreateParentUpdate? Parent, CreateParentUpdate? Parent,
WorldSession.EntityPositionUpdate? Position, WorldSession.EntityPositionUpdate? Position,

View file

@ -477,6 +477,30 @@ public sealed class LiveEntityRuntime
return true; return true;
} }
public void SetEffectProfile(uint serverGuid, ILiveEntityEffectProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException(
$"Cannot bind an effect profile before live entity 0x{serverGuid:X8} exists.");
record.EffectProfile = profile;
}
public bool TryGetEffectProfile(
uint serverGuid,
out ILiveEntityEffectProfile profile)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.EffectProfile is { } found)
{
profile = found;
return true;
}
profile = null!;
return false;
}
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
_inbound.TryGetSnapshot(guid, out spawn); _inbound.TryGetSnapshot(guid, out spawn);

View file

@ -1,4 +1,5 @@
using AcDream.Core.Rendering.Wb; using AcDream.Core.Rendering.Wb;
using AcDream.Content.Vfx;
using BCnEncoder.Decoder; using BCnEncoder.Decoder;
using BCnEncoder.ImageSharp; using BCnEncoder.ImageSharp;
using BCnEncoder.Shared; using BCnEncoder.Shared;
@ -31,6 +32,7 @@ namespace AcDream.Content;
public sealed class MeshExtractor { public sealed class MeshExtractor {
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly RetailPhysicsScriptLoader _physicsScripts;
// Cache for decoded textures to avoid redundant BCn decoding // Cache for decoded textures to avoid redundant BCn decoding
private readonly ConcurrentQueue<uint> _decodedTextureLru = new(); private readonly ConcurrentQueue<uint> _decodedTextureLru = new();
@ -60,6 +62,7 @@ public sealed class MeshExtractor {
_dats = dats; _dats = dats;
_logger = logger; _logger = logger;
_sideStagedSink = sideStagedSink; _sideStagedSink = sideStagedSink;
_physicsScripts = new RetailPhysicsScriptLoader(dats.Portal);
} }
/// <summary> /// <summary>
@ -151,9 +154,10 @@ public sealed class MeshExtractor {
} }
private void CollectEmittersFromScript(uint scriptId, List<StagedEmitter> emitters, CancellationToken ct) { private void CollectEmittersFromScript(uint scriptId, List<StagedEmitter> emitters, CancellationToken ct) {
if (_dats.Portal.TryGet<PhysicsScript>(scriptId, out var script)) { var script = _physicsScripts.LoadPhysicsScript(scriptId);
if (script is not null) {
foreach (var hook in script.ScriptData) { foreach (var hook in script.ScriptData) {
if (hook.Hook.HookType == AnimationHookType.CreateParticle && hook.Hook is CreateParticleHook particleHook) { if (hook.Hook is CreateParticleHook particleHook) {
if (_dats.Portal.TryGet<ParticleEmitter>(particleHook.EmitterInfoId.DataId, out var emitter)) { if (_dats.Portal.TryGet<ParticleEmitter>(particleHook.EmitterInfoId.DataId, out var emitter)) {
emitters.Add(new StagedEmitter { emitters.Add(new StagedEmitter {
Emitter = emitter, Emitter = emitter,

View file

@ -0,0 +1,48 @@
using AcDream.Core.Vfx;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
namespace AcDream.Content.Vfx;
/// <summary>
/// Narrow compatibility seam for the one retail hook schema that
/// Chorizite.DatReaderWriter 2.1.7 decodes incorrectly.
/// </summary>
/// <remarks>
/// Retail container dispatch is ported from <c>AnimFrame::UnPack</c>
/// (<c>0x0051F8E0</c>) and <c>PhysicsScript::UnPack</c>
/// (<c>0x005218B0</c>). The substituted payload shape is proven by
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>).
/// </remarks>
public static class RetailAnimationHookReader
{
/// <summary>
/// Reads one hook at the current cursor. Ordinary hooks are delegated to
/// the package decoder; CreateBlockingParticle is decoded through the
/// retail CreateParticle-shaped model.
/// </summary>
public static AnimationHook Read(DatBinReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
// PhysicsScriptData::UnPack / AnimFrame::UnPack peek the type and
// restore the cursor before dispatch. Preserve that exact contract so
// the selected hook consumes its own shared header exactly once.
var type = (AnimationHookType)reader.ReadUInt32();
reader.Skip(-sizeof(uint));
AnimationHook? hook = type is AnimationHookType.CreateBlockingParticle
? new RetailCreateBlockingParticleHook()
: AnimationHook.Unpack(reader, type);
if (hook is null)
throw new InvalidDataException(
$"Unsupported animation hook type 0x{(uint)type:X8} at byte {reader.Offset}.");
if (type is AnimationHookType.CreateBlockingParticle)
hook.Unpack(reader);
return hook;
}
}

View file

@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
namespace AcDream.Content.Vfx;
/// <summary>
/// Raw Animation loader using <see cref="RetailAnimationHookReader"/> for
/// frame hooks and the package's ordinary Frame model for transforms.
/// </summary>
/// <remarks>
/// Container order and field widths match <c>CAnimation::UnPack</c> at
/// <c>0x0051FB50</c> and <c>AnimFrame::UnPack</c> at <c>0x0051F8E0</c>.
/// </remarks>
public sealed class RetailAnimationLoader : IAnimationLoader
{
private readonly Func<uint, byte[]?> _readRaw;
private readonly DatDatabase? _database;
private readonly ConcurrentDictionary<uint, Lazy<Animation?>> _cache = new();
public RetailAnimationLoader(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
_database = dats.Portal;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;
}
public RetailAnimationLoader(IDatDatabase portal)
{
ArgumentNullException.ThrowIfNull(portal);
_database = portal.Db;
_readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;
}
public Animation? LoadAnimation(uint id)
{
if (!IsAnimationDid(id))
return null;
return _cache.GetOrAdd(
id,
key => new Lazy<Animation?>(
() => LoadUncached(key),
LazyThreadSafetyMode.ExecutionAndPublication)).Value;
}
private Animation? LoadUncached(uint id)
{
byte[]? bytes = _readRaw(id);
if (bytes is null)
return null;
Animation animation = Parse(bytes, _database);
if (animation.Id != id)
throw new InvalidDataException(
$"Animation entry 0x{id:X8} contained id 0x{animation.Id:X8}.");
return animation;
}
public static Animation Parse(
ReadOnlyMemory<byte> bytes,
DatDatabase? database = null)
{
var reader = new DatBinReader(bytes, database);
var animation = new Animation
{
Id = reader.ReadUInt32(),
Flags = (AnimationFlags)reader.ReadUInt32(),
NumParts = reader.ReadUInt32(),
};
uint frameCount = reader.ReadUInt32();
if ((animation.Flags & AnimationFlags.PosFrames) != 0)
{
for (uint i = 0; i < frameCount; i++)
animation.PosFrames.Add(reader.ReadItem<Frame>());
}
for (uint frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
var frame = new AnimationFrame(animation.NumParts);
for (uint partIndex = 0; partIndex < animation.NumParts; partIndex++)
frame.Frames.Add(reader.ReadItem<Frame>());
uint hookCount = reader.ReadUInt32();
for (uint hookIndex = 0; hookIndex < hookCount; hookIndex++)
frame.Hooks.Add(RetailAnimationHookReader.Read(reader));
animation.PartFrames.Add(frame);
}
if (reader.Offset != reader.Length)
throw new InvalidDataException(
$"Animation 0x{animation.Id:X8} consumed {reader.Offset} of {reader.Length} bytes.");
return animation;
}
public static bool IsAnimationDid(uint did) =>
// AC data IDs reserve only the high byte for DBObj type.
(did & 0xFF000000u) == 0x03000000u;
}

View file

@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Content.Vfx;
/// <summary>
/// Raw PhysicsScript loader that substitutes the retail hook reader while
/// leaving every ordinary hook in Chorizite.DatReaderWriter's decoder.
/// </summary>
/// <remarks>
/// Container order and field widths match <c>PhysicsScript::UnPack</c> at
/// <c>0x005218B0</c>.
/// </remarks>
public sealed class RetailPhysicsScriptLoader
{
private readonly Func<uint, byte[]?> _readRaw;
private readonly DatDatabase? _database;
private readonly ConcurrentDictionary<uint, Lazy<DatPhysicsScript?>> _cache = new();
public RetailPhysicsScriptLoader(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
_database = dats.Portal;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;
}
public RetailPhysicsScriptLoader(IDatDatabase portal)
{
ArgumentNullException.ThrowIfNull(portal);
_database = portal.Db;
_readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;
}
/// <summary>Loads and caches one immutable portal-DAT PhysicsScript.</summary>
public DatPhysicsScript? LoadPhysicsScript(uint id)
{
if (!AcDream.Core.Vfx.PhysicsScriptTableResolver.IsPhysicsScriptDid(id))
return null;
return _cache.GetOrAdd(
id,
key => new Lazy<DatPhysicsScript?>(
() => LoadUncached(key),
LazyThreadSafetyMode.ExecutionAndPublication)).Value;
}
private DatPhysicsScript? LoadUncached(uint id)
{
byte[]? bytes = _readRaw(id);
if (bytes is null)
return null;
DatPhysicsScript script = Parse(bytes, _database);
if (script.Id != id)
throw new InvalidDataException(
$"PhysicsScript entry 0x{id:X8} contained id 0x{script.Id:X8}.");
return script;
}
/// <summary>
/// Parses a complete raw DAT entry. Full consumption is part of the
/// contract: a malformed hook cannot silently shift the following entry.
/// </summary>
public static DatPhysicsScript Parse(
ReadOnlyMemory<byte> bytes,
DatDatabase? database = null)
{
var reader = new DatBinReader(bytes, database);
var script = new DatPhysicsScript
{
Id = reader.ReadUInt32(),
};
uint count = reader.ReadUInt32();
for (uint i = 0; i < count; i++)
{
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = reader.ReadDouble(),
Hook = RetailAnimationHookReader.Read(reader),
});
}
// PhysicsScript::UnPack 0x005218B0 qsorts the decoded pointer array
// with PhysicsScriptData::Sort 0x00521600 before publishing its tail.
// The runtime consumes one forward cursor, so this is behavioral—not
// merely canonicalization of the DAT representation.
script.ScriptData.Sort(static (left, right) =>
left.StartTime.CompareTo(right.StartTime));
EnsureFullyConsumed(reader, nameof(DatPhysicsScript), script.Id);
return script;
}
private static void EnsureFullyConsumed(DatBinReader reader, string type, uint id)
{
if (reader.Offset != reader.Length)
throw new InvalidDataException(
$"{type} 0x{id:X8} consumed {reader.Offset} of {reader.Length} bytes.");
}
}

View file

@ -1,6 +1,7 @@
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using AcDream.Core.Physics;
namespace AcDream.Core.Meshing; namespace AcDream.Core.Meshing;
@ -42,7 +43,11 @@ public static class MotionResolver
/// to <c>Setup.PlacementFrames</c>. /// to <c>Setup.PlacementFrames</c>.
/// </summary> /// </summary>
/// <param name="setup">The entity's base Setup dat.</param> /// <param name="setup">The entity's base Setup dat.</param>
/// <param name="dats">Dat collection used to load the linked MotionTable + Animation.</param> /// <param name="dats">Dat collection used to load the linked MotionTable.</param>
/// <param name="animationLoader">
/// Central animation loader. Production uses the retail compatibility
/// reader so blocking-particle hooks retain their inherited payload.
/// </param>
/// <param name="motionTableIdOverride"> /// <param name="motionTableIdOverride">
/// Optional override for the motion table id. Defaults to /// Optional override for the motion table id. Defaults to
/// <c>Setup.DefaultMotionTable</c>. The server's CreateObject can /// <c>Setup.DefaultMotionTable</c>. The server's CreateObject can
@ -71,11 +76,14 @@ public static class MotionResolver
public static IdleCycle? GetIdleCycle( public static IdleCycle? GetIdleCycle(
Setup setup, Setup setup,
DatCollection dats, DatCollection dats,
IAnimationLoader animationLoader,
uint? motionTableIdOverride = null, uint? motionTableIdOverride = null,
ushort? stanceOverride = null, ushort? stanceOverride = null,
ushort? commandOverride = null) ushort? commandOverride = null)
{ {
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride); var resolved = ResolveIdleCycleInternal(
setup, dats, animationLoader,
motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null; if (resolved is null) return null;
var (anim, ad) = resolved.Value; var (anim, ad) = resolved.Value;
@ -116,11 +124,14 @@ public static class MotionResolver
public static AnimationFrame? GetIdleFrame( public static AnimationFrame? GetIdleFrame(
Setup setup, Setup setup,
DatCollection dats, DatCollection dats,
IAnimationLoader animationLoader,
uint? motionTableIdOverride = null, uint? motionTableIdOverride = null,
ushort? stanceOverride = null, ushort? stanceOverride = null,
ushort? commandOverride = null) ushort? commandOverride = null)
{ {
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride); var resolved = ResolveIdleCycleInternal(
setup, dats, animationLoader,
motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null; if (resolved is null) return null;
var (animation, animData) = resolved.Value; var (animation, animData) = resolved.Value;
int frameIdx = animData.LowFrame; int frameIdx = animData.LowFrame;
@ -138,12 +149,14 @@ public static class MotionResolver
private static (Animation, AnimData)? ResolveIdleCycleInternal( private static (Animation, AnimData)? ResolveIdleCycleInternal(
Setup setup, Setup setup,
DatCollection dats, DatCollection dats,
IAnimationLoader animationLoader,
uint? motionTableIdOverride, uint? motionTableIdOverride,
ushort? stanceOverride, ushort? stanceOverride,
ushort? commandOverride) ushort? commandOverride)
{ {
ArgumentNullException.ThrowIfNull(setup); ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animationLoader);
uint mtableId = motionTableIdOverride ?? (uint)setup.DefaultMotionTable; uint mtableId = motionTableIdOverride ?? (uint)setup.DefaultMotionTable;
if (mtableId == 0) return null; if (mtableId == 0) return null;
@ -238,7 +251,7 @@ public static class MotionResolver
uint animId = (uint)animData.AnimId; uint animId = (uint)animData.AnimId;
if (animId == 0) return null; if (animId == 0) return null;
var animation = dats.Get<Animation>(animId); var animation = animationLoader.LoadAnimation(animId);
if (animation is null) return null; if (animation is null) return null;
if (animation.PartFrames.Count == 0) return null; if (animation.PartFrames.Count == 0) return null;

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -19,17 +18,6 @@ public interface IAnimationLoader
Animation? LoadAnimation(uint id); Animation? LoadAnimation(uint id);
} }
/// <summary>
/// Production implementation of <see cref="IAnimationLoader"/> backed by
/// a <see cref="DatCollection"/>.
/// </summary>
public sealed class DatCollectionLoader : IAnimationLoader
{
private readonly DatCollection _dats;
public DatCollectionLoader(DatCollection dats) => _dats = dats;
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(id);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// AnimationSequencer — adapter rehosted on the verbatim retail CSequence core // AnimationSequencer — adapter rehosted on the verbatim retail CSequence core
// (R1-P5, docs/research/2026-07-02-r1-csequence/r1-gap-map.md §3 P5). // (R1-P5, docs/research/2026-07-02-r1-csequence/r1-gap-map.md §3 P5).
@ -275,8 +263,8 @@ public sealed class AnimationSequencer
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param> /// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param> /// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
/// <param name="loader"> /// <param name="loader">
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production, /// Animation loader. Production supplies the centralized retail-compatible
/// or inject a test double in unit tests. /// content reader; unit tests may inject a test double.
/// </param> /// </param>
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader) public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
{ {

View file

@ -24,9 +24,10 @@ namespace AcDream.Core.Vfx;
/// <item><description> /// <item><description>
/// <see cref="CreateBlockingParticleHook"/> — has the same payload /// <see cref="CreateBlockingParticleHook"/> — has the same payload
/// as CreateParticleHook, but suppresses creation while the same nonzero /// as CreateParticleHook, but suppresses creation while the same nonzero
/// logical emitter ID is live. It does not pause animation. The dependency /// logical emitter ID is live. It does not pause animation. Production DAT
/// currently omits that inherited payload; the retail compatibility reader /// loading substitutes <see cref="RetailCreateBlockingParticleHook"/> so
/// owns its decoding. /// the inherited payload is retained despite the dependency's header-only
/// model. Suppression itself lands with live emitter bindings in Step 5.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter /// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
@ -102,6 +103,13 @@ public sealed class ParticleHookSink : IAnimationHookSink
{ {
switch (hook) switch (hook)
{ {
case RetailCreateBlockingParticleHook:
// The centralized content reader now preserves the complete
// retail payload. Step 5 owns live logical-ID suppression and
// attachment semantics; do not silently treat blocking as a
// normal replacement before that mechanism lands.
break;
case CreateParticleHook cph: case CreateParticleHook cph:
SpawnFromHook(entityId, entityWorldPosition, SpawnFromHook(entityId, entityWorldPosition,
emitterInfoId: (uint)cph.EmitterInfoId, emitterInfoId: (uint)cph.EmitterInfoId,
@ -111,11 +119,8 @@ public sealed class ParticleHookSink : IAnimationHookSink
break; break;
case CreateBlockingParticleHook: case CreateBlockingParticleHook:
// Retail gives this the full CreateParticle payload and // Defensive package-decoder shape. Production raw loaders
// suppresses creation only while the same nonzero logical ID // replace this header-only model with the retail subclass.
// is live; it never pauses animation. The dependency loses
// that payload, so TS-11 remains a no-op until the centralized
// raw-hook compatibility reader supplies the retail shape.
break; break;
case DestroyParticleHook dph: case DestroyParticleHook dph:
@ -128,9 +133,8 @@ public sealed class ParticleHookSink : IAnimationHookSink
_system.StopEmitter(handleToStop, fadeOut: true); _system.StopEmitter(handleToStop, fadeOut: true);
break; break;
// DefaultScript / CallPES are wired when PhysicsScript loading // DefaultScript / CallPES are routed by the entity-effect owner in
// is available. They arrive at the sink but we can't act until // Step 4. ParticleHookSink intentionally owns particles only.
// the script table returns a real emitter list.
} }
} }

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.Types; using DatReaderWriter.Types;
// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in // Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in
// VfxModel.cs; alias the dat-reader type to avoid name collision. // VfxModel.cs; alias the dat-reader type to avoid name collision.
@ -82,8 +81,8 @@ public sealed class PhysicsScriptRunner
/// <summary> /// <summary>
/// Preferred ctor — resolver delegate lets this class stay /// Preferred ctor — resolver delegate lets this class stay
/// DatCollection-free for testing. Production code will pass /// DAT-reader-free for testing. Production passes the centralized retail
/// a lambda that hits <c>DatCollection.Get&lt;PhysicsScript&gt;</c>. /// compatibility loader so CreateBlockingParticle cannot misalign hooks.
/// </summary> /// </summary>
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink) public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
{ {
@ -91,21 +90,6 @@ public sealed class PhysicsScriptRunner
_sink = sink ?? throw new ArgumentNullException(nameof(sink)); _sink = sink ?? throw new ArgumentNullException(nameof(sink));
} }
/// <summary>
/// Convenience ctor — builds a resolver around a <see cref="DatCollection"/>.
/// </summary>
public PhysicsScriptRunner(DatCollection dats, IAnimationHookSink sink)
: this(id => SafeGet(dats, id), sink)
{
}
private static DatPhysicsScript? SafeGet(DatCollection dats, uint id)
{
if (dats is null) return null;
try { return dats.Get<DatPhysicsScript>(id); }
catch { return null; }
}
/// <summary>Number of scripts currently active (for telemetry).</summary> /// <summary>Number of scripts currently active (for telemetry).</summary>
public int ActiveScriptCount => _active.Count; public int ActiveScriptCount => _active.Count;

View file

@ -0,0 +1,64 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
/// <summary>
/// Exact retail PhysicsScriptTable lookup: hash by raw script type, then scan
/// that type's entries in stored DAT order for the first upper threshold.
/// </summary>
/// <remarks>
/// Port of <c>PhysicsScriptTable::GetScript</c> at <c>0x00521E40</c> and
/// <c>PhysicsScriptTableData::GetScript</c> at <c>0x00521A20</c>.
/// </remarks>
public sealed class PhysicsScriptTableResolver
{
private readonly Func<uint, PhysicsScriptTable?> _loadTable;
public PhysicsScriptTableResolver(Func<uint, PhysicsScriptTable?> loadTable)
=> _loadTable = loadTable ?? throw new ArgumentNullException(nameof(loadTable));
/// <summary>
/// Returns the selected PhysicsScript DID, or null for a missing table or
/// type, an unordered/above-range intensity, or an invalid script DID.
/// </summary>
public uint? Resolve(uint tableDid, uint rawScriptType, float intensity)
{
if (!IsPhysicsScriptTableDid(tableDid))
return null;
PhysicsScriptTable? table = _loadTable(tableDid);
if (table is null
|| table.Id != tableDid
|| !table.ScriptTable.TryGetValue(
unchecked((PlayScript)rawScriptType),
out PhysicsScriptTableData? data))
{
return null;
}
// Retail compares intensity <= Mod. Do not sort: equal and duplicate
// thresholds resolve to the first stored entry. IEEE comparisons make
// NaN fall through all entries, +infinity fall through finite entries,
// and -infinity select the first finite threshold.
foreach (ScriptAndModData entry in data.Scripts)
{
if (intensity <= entry.Mod)
{
uint scriptDid = entry.ScriptId.DataId;
return IsPhysicsScriptDid(scriptDid) ? scriptDid : null;
}
}
return null;
}
public static bool IsPhysicsScriptDid(uint did) =>
// AC data IDs encode the DBObj type in the high byte; the remaining
// 24 bits are the file index and must not be truncated to 16 bits.
(did & 0xFF000000u) == 0x33000000u;
public static bool IsPhysicsScriptTableDid(uint did) =>
(did & 0xFF000000u) == 0x34000000u;
}

View file

@ -0,0 +1,20 @@
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
/// <summary>
/// Retail's CreateBlockingParticle hook has the complete CreateParticle
/// payload. Chorizite.DatReaderWriter 2.1.7 models it as a header-only hook,
/// so the content compatibility reader instantiates this derived shape.
/// </summary>
/// <remarks>
/// Retail oracle: <c>CreateBlockingParticleHook::Execute</c> at
/// <c>0x00526EF0</c>. The inherited emitter DID, part, Frame, and logical
/// emitter ID are consumed by <c>ParticleManager::CreateBlockingParticleEmitter</c>
/// at <c>0x0051B8A0</c>.
/// </remarks>
public sealed class RetailCreateBlockingParticleHook : CreateParticleHook
{
public override AnimationHookType HookType => AnimationHookType.CreateBlockingParticle;
}

View file

@ -0,0 +1,83 @@
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net.Messages;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectProfileTests
{
[Fact]
public void DatStatic_RetainsSetupDirectScriptAndTypedTable()
{
Setup setup = BuildSetup();
EntityEffectProfile profile = EntityEffectProfile.CreateDatStatic(setup);
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
Assert.False(profile.HasNetworkDescription);
}
[Fact]
public void LivePhysicsDescriptionWithoutPeTableClearsSetupTable()
{
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
default);
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
Assert.True(profile.HasNetworkDescription);
}
[Fact]
public void LivePhysicsDescriptionReplacesTableAndDrivesTypedDefault()
{
PhysicsSpawnData physics = default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
DefaultScriptType = 0xA5A5A5A5u,
DefaultScriptIntensity = 0.75f,
};
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
physics);
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
Assert.Equal(0.75f, profile.DefaultScriptIntensity);
}
[Fact]
public void LaterNetworkDescriptionWithPresentZeroDoesNotRestoreSetup()
{
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
DefaultScriptType = 7u,
DefaultScriptIntensity = 1f,
});
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0u,
DefaultScriptType = 0u,
DefaultScriptIntensity = 0f,
});
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
}
private static Setup BuildSetup() => new()
{
DefaultScript = 0x330000AAu,
DefaultScriptTable = 0x340000BBu,
};
}

View file

@ -27,6 +27,8 @@ public sealed class LiveEntityRuntimeTests
public WorldEntity Entity { get; } = entity; public WorldEntity Entity { get; } = entity;
} }
private sealed class EffectProfile : ILiveEntityEffectProfile { }
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{ {
public int RegisterCount { get; private set; } public int RegisterCount { get; private set; }
@ -90,6 +92,33 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
} }
[Fact]
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
{
const uint guid = 0x70000030u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 6, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
var profile = new EffectProfile();
runtime.SetEffectProfile(guid, profile);
runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
Assert.True(runtime.TryGetEffectProfile(guid, out var retained));
Assert.Same(profile, retained);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, spawn.InstanceSequence),
isLocalPlayer: false));
Assert.False(runtime.TryGetEffectProfile(guid, out _));
}
[Fact] [Fact]
public void InitialChildCreate_PreservesParentEventQueuedForMissingChild() public void InitialChildCreate_PreservesParentEventQueuedForMissingChild()
{ {

View file

@ -11,6 +11,8 @@ internal static class ProjectileVfxDatFixtures
{ {
internal static readonly byte[] PhysicsScriptCreateBlockingThenAnimationDone = BuildPhysicsScript(); internal static readonly byte[] PhysicsScriptCreateBlockingThenAnimationDone = BuildPhysicsScript();
internal static readonly byte[] AnimationCreateBlockingThenAnimationDone = BuildAnimation(); internal static readonly byte[] AnimationCreateBlockingThenAnimationDone = BuildAnimation();
internal static readonly byte[] OrdinaryPhysicsScript = BuildOrdinaryPhysicsScript();
internal static readonly byte[] OrdinaryAnimation = BuildOrdinaryAnimation();
private static byte[] BuildPhysicsScript() private static byte[] BuildPhysicsScript()
{ {
@ -95,6 +97,30 @@ internal static class ProjectileVfxDatFixtures
pos += 4; pos += 4;
} }
} }
private static byte[] BuildOrdinaryPhysicsScript()
{
var bytes = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x3300F002u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), 1u);
BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(8), 2.5);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), 0x04u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 1u);
return bytes;
}
private static byte[] BuildOrdinaryAnimation()
{
var bytes = new byte[28];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x0300F002u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), 1u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), 1u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x04u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24), 1u);
return bytes;
}
} }
public sealed class ProjectileVfxDatFixtureTests public sealed class ProjectileVfxDatFixtureTests

View file

@ -0,0 +1,242 @@
using System.Numerics;
using System.Diagnostics.CodeAnalysis;
using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using DatAnimation = DatReaderWriter.DBObjs.Animation;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Content.Tests.Vfx;
public sealed class RetailDatLoaderTests
{
private sealed class RawDatabase : IDatDatabase
{
private readonly Dictionary<uint, byte[]> _entries = new();
public DatDatabase Db => null!;
public int Iteration => 0;
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
_entries.TryGetValue(fileId, out value);
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
{
if (!_entries.TryGetValue(fileId, out byte[]? value))
{
bytesRead = 0;
return false;
}
if (bytes.Length < value.Length)
bytes = new byte[value.Length];
value.CopyTo(bytes, 0);
bytesRead = value.Length;
return true;
}
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj => false;
public void Dispose() { }
}
[Fact]
public void PhysicsScript_BlockingHookRetainsPayloadAndFollowingCursor()
{
DatPhysicsScript script = RetailPhysicsScriptLoader.Parse(
ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone);
Assert.Equal(0x3300F001u, script.Id);
Assert.Equal(2, script.ScriptData.Count);
Assert.Equal(0.0, script.ScriptData[0].StartTime);
var blocking = Assert.IsType<RetailCreateBlockingParticleHook>(
script.ScriptData[0].Hook);
Assert.Equal(0x3200F001u, blocking.EmitterInfoId.DataId);
Assert.Equal(0xFFFFFFFFu, blocking.PartIndex);
Assert.Equal(new Vector3(1f, 2f, 3f), blocking.Offset.Origin);
Assert.Equal(new Quaternion(0.5f, -0.5f, -0.5f, 0.5f),
blocking.Offset.Orientation);
Assert.Equal(7u, blocking.EmitterId);
Assert.Equal(1.25, script.ScriptData[1].StartTime);
Assert.IsType<AnimationDoneHook>(script.ScriptData[1].Hook);
}
[Fact]
public void Animation_BlockingHookRetainsPayloadAndFollowingCursor()
{
DatAnimation animation = RetailAnimationLoader.Parse(
ProjectileVfxDatFixtures.AnimationCreateBlockingThenAnimationDone);
Assert.Equal(0x0300F001u, animation.Id);
AnimationFrame frame = Assert.Single(animation.PartFrames);
Assert.Equal(2, frame.Hooks.Count);
var blocking = Assert.IsType<RetailCreateBlockingParticleHook>(frame.Hooks[0]);
Assert.Equal(0x3200F002u, blocking.EmitterInfoId.DataId);
Assert.Equal(0u, blocking.PartIndex);
Assert.Equal(new Vector3(-1f, 4f, 0.25f), blocking.Offset.Origin);
Assert.Equal(Quaternion.Identity, blocking.Offset.Orientation);
Assert.Equal(9u, blocking.EmitterId);
Assert.IsType<AnimationDoneHook>(frame.Hooks[1]);
}
[Fact]
public void OrdinaryPhysicsScript_MatchesPackageDecoder()
{
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript;
DatPhysicsScript retail = RetailPhysicsScriptLoader.Parse(bytes);
var package = new DatPhysicsScript();
var reader = new DatBinReader(bytes);
Assert.True(package.Unpack(reader));
Assert.Equal(reader.Length, reader.Offset);
Assert.Equal(package.Id, retail.Id);
Assert.Equal(package.ScriptData.Count, retail.ScriptData.Count);
Assert.Equal(package.ScriptData[0].StartTime, retail.ScriptData[0].StartTime);
Assert.Equal(package.ScriptData[0].Hook.HookType, retail.ScriptData[0].Hook.HookType);
Assert.Equal(package.ScriptData[0].Hook.Direction, retail.ScriptData[0].Hook.Direction);
}
[Fact]
public void OrdinaryAnimation_MatchesPackageDecoder()
{
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryAnimation;
DatAnimation retail = RetailAnimationLoader.Parse(bytes);
var package = new DatAnimation();
var reader = new DatBinReader(bytes);
Assert.True(package.Unpack(reader));
Assert.Equal(reader.Length, reader.Offset);
Assert.Equal(package.Id, retail.Id);
Assert.Equal(package.Flags, retail.Flags);
Assert.Equal(package.NumParts, retail.NumParts);
Assert.Equal(package.PartFrames.Count, retail.PartFrames.Count);
Assert.Equal(package.PartFrames[0].Hooks[0].HookType,
retail.PartFrames[0].Hooks[0].HookType);
Assert.Equal(package.PartFrames[0].Hooks[0].Direction,
retail.PartFrames[0].Hooks[0].Direction);
}
[Fact]
public void PhysicsScript_SortsHooksByRetailStartTimeAfterDecode()
{
byte[] bytes = ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone.ToArray();
// The fixture stores starts 0.0 then 1.25. Make the first later than
// the second without changing either hook payload or cursor.
System.Buffers.Binary.BinaryPrimitives.WriteDoubleLittleEndian(
bytes.AsSpan(8), 2.0);
DatPhysicsScript script = RetailPhysicsScriptLoader.Parse(bytes);
Assert.Equal(1.25, script.ScriptData[0].StartTime);
Assert.IsType<AnimationDoneHook>(script.ScriptData[0].Hook);
Assert.Equal(2.0, script.ScriptData[1].StartTime);
Assert.IsType<RetailCreateBlockingParticleHook>(script.ScriptData[1].Hook);
}
[Fact]
public void AnimationDidValidation_UsesRetailHighByte()
{
Assert.True(RetailAnimationLoader.IsAnimationDid(0x03010000u));
Assert.True(RetailAnimationLoader.IsAnimationDid(0x03FFFFFFu));
Assert.False(RetailAnimationLoader.IsAnimationDid(0x04010000u));
}
[Fact]
public void Loaders_AcceptHighIndexesCacheIdentityAndRejectEmbeddedIdMismatch()
{
const uint scriptDid = 0x33010000u;
const uint animationDid = 0x03010000u;
var portal = new RawDatabase();
byte[] scriptBytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
byte[] animationBytes = ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(scriptBytes, scriptDid);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(animationBytes, animationDid);
portal.Add(scriptDid, scriptBytes);
portal.Add(animationDid, animationBytes);
var scripts = new RetailPhysicsScriptLoader(portal);
var animations = new RetailAnimationLoader(portal);
DatPhysicsScript script = Assert.IsType<DatPhysicsScript>(scripts.LoadPhysicsScript(scriptDid));
DatAnimation animation = Assert.IsType<DatAnimation>(animations.LoadAnimation(animationDid));
Assert.Same(script, scripts.LoadPhysicsScript(scriptDid));
Assert.Same(animation, animations.LoadAnimation(animationDid));
const uint wrongScriptKey = 0x33010001u;
const uint wrongAnimationKey = 0x03010001u;
portal.Add(wrongScriptKey, scriptBytes);
portal.Add(wrongAnimationKey, animationBytes);
Assert.Throws<InvalidDataException>(() => scripts.LoadPhysicsScript(wrongScriptKey));
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
}
[Fact]
public void Parsers_RejectTrailingOrTruncatedEntries()
{
byte[] script = ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone;
byte[] animation = ProjectileVfxDatFixtures.AnimationCreateBlockingThenAnimationDone;
Assert.ThrowsAny<Exception>(() =>
RetailPhysicsScriptLoader.Parse(script.AsMemory(0, script.Length - 1)));
Assert.ThrowsAny<Exception>(() =>
RetailAnimationLoader.Parse(animation.AsMemory(0, animation.Length - 1)));
Assert.Throws<InvalidDataException>(() =>
RetailPhysicsScriptLoader.Parse(script.Concat(new byte[] { 0 }).ToArray()));
Assert.Throws<InvalidDataException>(() =>
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
}
[Fact]
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
{
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
byte[] animation = ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
script.AsSpan(16), 0xDEADBEEFu);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
animation.AsSpan(20), 0xDEADBEEFu);
Assert.ThrowsAny<Exception>(() => RetailPhysicsScriptLoader.Parse(script));
Assert.ThrowsAny<Exception>(() => RetailAnimationLoader.Parse(animation));
}
[Fact]
public void InstalledBlockingScripts_ParseThroughRetailShape_WhenDatsAvailable()
{
string? datDir = ResolveDatDirectory();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loader = new RetailPhysicsScriptLoader(dats);
foreach (uint id in new[] { 0x33000AEAu, 0x33000AF8u })
{
DatPhysicsScript script = Assert.IsType<DatPhysicsScript>(
loader.LoadPhysicsScript(id));
Assert.Contains(script.ScriptData,
item => item.Hook is RetailCreateBlockingParticleHook);
}
}
private static string? ResolveDatDirectory()
{
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured)
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
{
return configured;
}
const string installed = @"C:\Turbine\Asheron's Call";
return File.Exists(Path.Combine(installed, "client_portal.dat"))
? installed
: null;
}
}

View file

@ -0,0 +1,117 @@
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptTableResolverTests
{
private const uint TableDid = 0x34000005u;
private const uint RawType = 0x00000016u;
[Fact]
public void Resolve_UsesFirstStoredUpperThresholdWithoutSorting()
{
PhysicsScriptTable table = BuildTable(
(1.0f, 0x33000011u),
(0.0f, 0x33000022u));
var resolver = new PhysicsScriptTableResolver(
id => id == TableDid ? table : null);
Assert.Equal(0x33000011u, resolver.Resolve(TableDid, RawType, -0.25f));
Assert.Equal(0x33000011u, resolver.Resolve(TableDid, RawType, 0.0f));
}
[Fact]
public void Resolve_EqualityAndDuplicateThresholdChooseFirst()
{
PhysicsScriptTable table = BuildTable(
(0.5f, 0x33000031u),
(0.5f, 0x33000032u),
(1.0f, 0x33000033u));
var resolver = new PhysicsScriptTableResolver(_ => table);
Assert.Equal(0x33000031u, resolver.Resolve(TableDid, RawType, 0.5f));
Assert.Equal(0x33000033u, resolver.Resolve(TableDid, RawType, 0.75f));
}
[Fact]
public void Resolve_PreservesRetailIeeeComparisonBoundaries()
{
PhysicsScriptTable table = BuildTable(
(0.0f, 0x33000041u),
(1.0f, 0x33000042u));
var resolver = new PhysicsScriptTableResolver(_ => table);
Assert.Equal(0x33000041u,
resolver.Resolve(TableDid, RawType, float.NegativeInfinity));
Assert.Null(resolver.Resolve(TableDid, RawType, float.PositiveInfinity));
Assert.Null(resolver.Resolve(TableDid, RawType, float.NaN));
Assert.Null(resolver.Resolve(TableDid, RawType, 1.0001f));
}
[Fact]
public void Resolve_MissingOrInvalidInputsReturnNoScript()
{
PhysicsScriptTable invalidScript = BuildTable((1.0f, 0x32000001u));
var resolver = new PhysicsScriptTableResolver(
id => id == TableDid ? invalidScript : null);
Assert.Null(resolver.Resolve(0u, RawType, 0f));
Assert.Null(resolver.Resolve(0x35000001u, RawType, 0f));
Assert.Null(resolver.Resolve(TableDid, 0xA5A5A5A5u, 0f));
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
}
[Fact]
public void DidValidation_UsesRetailHighByteAndAcceptsLargeIndexes()
{
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x33010000u));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x33FFFFFFu));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x34010000u));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x34FFFFFFu));
Assert.False(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x34010000u));
Assert.False(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x33010000u));
}
[Fact]
public void Resolve_AcceptsHighIndexTableAndScriptIds()
{
const uint highTableDid = 0x34010000u;
PhysicsScriptTable table = BuildTable((1.0f, 0x33010000u));
table.Id = highTableDid;
var resolver = new PhysicsScriptTableResolver(
id => id == highTableDid ? table : null);
Assert.Equal(0x33010000u, resolver.Resolve(highTableDid, RawType, 0f));
}
[Fact]
public void Resolve_RejectsMismatchedEmbeddedTableId()
{
PhysicsScriptTable mismatched = BuildTable((1.0f, 0x33000051u));
mismatched.Id = 0x34000006u;
var resolver = new PhysicsScriptTableResolver(_ => mismatched);
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
}
private static PhysicsScriptTable BuildTable(
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;
}
}

View file

@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="C:\Users\erikn\source\repos\acdream\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" /> <ProjectReference Include="..\..\src\AcDream.Content\AcDream.Content.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -3,18 +3,21 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using AcDream.Content.Vfx;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using SysEnv = System.Environment; using SysEnv = System.Environment;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
using RetailBlockingHook = AcDream.Core.Vfx.RetailCreateBlockingParticleHook;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call"); "Documents", "Asheron's Call");
Console.WriteLine($"datDir = {datDir}"); Console.WriteLine($"datDir = {datDir}");
using var dats = new DatCollection(datDir, DatAccessType.Read); using var dats = new DatCollection(datDir, DatAccessType.Read);
var physicsScripts = new RetailPhysicsScriptLoader(dats);
uint[] roots = { 0x33000453u, 0x33000428u, 0x3300042Cu, 0x33000429u, 0x3300042Du }; uint[] roots = { 0x33000453u, 0x33000428u, 0x3300042Cu, 0x33000429u, 0x3300042Du };
@ -35,7 +38,8 @@ foreach (var root in roots)
Console.WriteLine($"{pad}PES 0x{pesId:X8} (already visited — cycle skip)"); Console.WriteLine($"{pad}PES 0x{pesId:X8} (already visited — cycle skip)");
return; return;
} }
if (!dats.TryGet<DatPhysicsScript>(pesId, out var ps) || ps is null) DatPhysicsScript? ps = physicsScripts.LoadPhysicsScript(pesId);
if (ps is null)
{ {
Console.WriteLine($"{pad}PES 0x{pesId:X8} NOT FOUND"); Console.WriteLine($"{pad}PES 0x{pesId:X8} NOT FOUND");
return; return;
@ -52,13 +56,18 @@ foreach (var root in roots)
Console.WriteLine($"{head} -> PES=0x{call.PES:X8} pause={call.Pause:F3}"); Console.WriteLine($"{head} -> PES=0x{call.PES:X8} pause={call.Pause:F3}");
WalkPes(call.PES, depth + 2, rootForReport); WalkPes(call.PES, depth + 2, rootForReport);
break; break;
case RetailBlockingHook blocking:
uint blockingEmitterId = (uint)blocking.EmitterInfoId;
Console.WriteLine($"{head} EmitterInfoId=0x{blockingEmitterId:X8} PartIdx={blocking.PartIndex} EmitterId={blocking.EmitterId} (blocking)");
DumpEmitter(blockingEmitterId, pad + " ", rootForReport);
break;
case CreateParticleHook cp: case CreateParticleHook cp:
uint eid = (uint)cp.EmitterInfoId; uint eid = (uint)cp.EmitterInfoId;
Console.WriteLine($"{head} EmitterInfoId=0x{eid:X8} PartIdx={cp.PartIndex} EmitterId={cp.EmitterId}"); Console.WriteLine($"{head} EmitterInfoId=0x{eid:X8} PartIdx={cp.PartIndex} EmitterId={cp.EmitterId}");
DumpEmitter(eid, pad + " ", rootForReport); DumpEmitter(eid, pad + " ", rootForReport);
break; break;
case CreateBlockingParticleHook _: case CreateBlockingParticleHook _:
Console.WriteLine($"{head} (package schema omits retail's inherited CreateParticle payload)"); Console.WriteLine($"{head} (unexpected package header-only shape)");
break; break;
case SoundHook sh: case SoundHook sh:
Console.WriteLine($"{head} sound=0x{(uint)sh.Id:X8}"); Console.WriteLine($"{head} sound=0x{(uint)sh.Id:X8}");

View file

@ -3,6 +3,8 @@ using System.Buffers.Binary;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using AcDream.Content.Vfx;
using AcDream.Core.Vfx;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO; using DatReaderWriter.Lib.IO;
@ -15,6 +17,8 @@ string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? @"C:\Turbine\Asheron's Call"; ?? @"C:\Turbine\Asheron's Call";
using var dats = new DatCollection(datDir, DatAccessType.Read); using var dats = new DatCollection(datDir, DatAccessType.Read);
var physicsScripts = new RetailPhysicsScriptLoader(dats);
var animations = new RetailAnimationLoader(dats);
Console.WriteLine($"DAT metadata audit: {datDir}"); Console.WriteLine($"DAT metadata audit: {datDir}");
var failures = new List<string>(); var failures = new List<string>();
var representativePes = new HashSet<uint>(); var representativePes = new HashSet<uint>();
@ -203,6 +207,17 @@ if (!blockingPes.SequenceEqual(expectedBlockingPes))
failures.Add($"raw blocking-PES inventory changed: got {string.Join(",", blockingPes.Select(x => $"0x{x:X8}"))}"); failures.Add($"raw blocking-PES inventory changed: got {string.Join(",", blockingPes.Select(x => $"0x{x:X8}"))}");
if (blockingAnimations.Count != 0) if (blockingAnimations.Count != 0)
failures.Add($"expected no raw blocking Animation hooks, got {string.Join(",", blockingAnimations.Select(x => $"0x{x:X8}"))}"); failures.Add($"expected no raw blocking Animation hooks, got {string.Join(",", blockingAnimations.Select(x => $"0x{x:X8}"))}");
foreach (uint id in expectedBlockingPes)
{
DatPhysicsScript? script = physicsScripts.LoadPhysicsScript(id);
if (script is null)
{
failures.Add($"retail loader could not parse blocking PES 0x{id:X8}");
continue;
}
if (!script.ScriptData.Any(entry => entry.Hook is RetailCreateBlockingParticleHook))
failures.Add($"blocking PES 0x{id:X8} did not retain the retail payload shape");
}
Console.WriteLine(); Console.WriteLine();
if (failures.Count == 0) if (failures.Count == 0)
@ -304,7 +319,7 @@ static void CollectAnimationIds(MotionData motion, HashSet<uint> ids, string ind
void DumpAnimation(uint id, HashSet<uint> pesRoots) void DumpAnimation(uint id, HashSet<uint> pesRoots)
{ {
DatAnimation? animation = SafeGet<DatAnimation>(id); DatAnimation? animation = animations.LoadAnimation(id);
if (animation is null) return; if (animation is null) return;
Console.WriteLine($"Animation 0x{id:X8}: frames={animation.PartFrames.Count}"); Console.WriteLine($"Animation 0x{id:X8}: frames={animation.PartFrames.Count}");
for (int frameIndex = 0; frameIndex < animation.PartFrames.Count; frameIndex++) for (int frameIndex = 0; frameIndex < animation.PartFrames.Count; frameIndex++)
@ -320,7 +335,7 @@ void DumpAnimation(uint id, HashSet<uint> pesRoots)
void DumpPhysicsScript(uint id, HashSet<uint> visited, string indent) void DumpPhysicsScript(uint id, HashSet<uint> visited, string indent)
{ {
if (!visited.Add(id)) return; if (!visited.Add(id)) return;
DatPhysicsScript? script = SafeGet<DatPhysicsScript>(id); DatPhysicsScript? script = physicsScripts.LoadPhysicsScript(id);
if (script is null) return; if (script is null) return;
Console.WriteLine($"{indent}PES 0x{id:X8}: hooks={script.ScriptData.Count}"); Console.WriteLine($"{indent}PES 0x{id:X8}: hooks={script.ScriptData.Count}");
if (expectedPhysicsScripts.TryGetValue(id, out var expected)) if (expectedPhysicsScripts.TryGetValue(id, out var expected))
@ -338,10 +353,13 @@ void DumpPhysicsScript(uint id, HashSet<uint> visited, string indent)
static string DescribeHook(AnimationHook hook) => hook switch static string DescribeHook(AnimationHook hook) => hook switch
{ {
CallPESHook call => $"CallPES PES=0x{call.PES:X8} pause={call.Pause:R}", CallPESHook call => $"CallPES PES=0x{call.PES:X8} pause={call.Pause:R}",
RetailCreateBlockingParticleHook blocking => $"CreateBlockingParticle emitter=0x{(uint)blocking.EmitterInfoId:X8} part={blocking.PartIndex} logical={blocking.EmitterId} "
+ $"offset=({blocking.Offset.Origin.X:R},{blocking.Offset.Origin.Y:R},{blocking.Offset.Origin.Z:R}) "
+ $"quat=({blocking.Offset.Orientation.X:R},{blocking.Offset.Orientation.Y:R},{blocking.Offset.Orientation.Z:R},{blocking.Offset.Orientation.W:R})",
CreateParticleHook create => $"CreateParticle emitter=0x{(uint)create.EmitterInfoId:X8} part={create.PartIndex} logical={create.EmitterId} " CreateParticleHook create => $"CreateParticle emitter=0x{(uint)create.EmitterInfoId:X8} part={create.PartIndex} logical={create.EmitterId} "
+ $"offset=({create.Offset.Origin.X:R},{create.Offset.Origin.Y:R},{create.Offset.Origin.Z:R}) " + $"offset=({create.Offset.Origin.X:R},{create.Offset.Origin.Y:R},{create.Offset.Origin.Z:R}) "
+ $"quat=({create.Offset.Orientation.X:R},{create.Offset.Orientation.Y:R},{create.Offset.Orientation.Z:R},{create.Offset.Orientation.W:R})", + $"quat=({create.Offset.Orientation.X:R},{create.Offset.Orientation.Y:R},{create.Offset.Orientation.Z:R},{create.Offset.Orientation.W:R})",
CreateBlockingParticleHook => "CreateBlockingParticle (package omits inherited payload)", CreateBlockingParticleHook => "CreateBlockingParticle (unexpected package header-only shape)",
_ => hook.HookType.ToString(), _ => hook.HookType.ToString(),
}; };

View file

@ -6,6 +6,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" /> <ProjectReference Include="..\..\src\AcDream.Content\AcDream.Content.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>