research(vfx): pin retail projectile and effect oracle

Establish the executable-backed PhysicsDesc, sequence-gate, PhysicsScript, CreateBlocking, particle-anchor, projectile, and Hidden-state behavior before changing runtime code. Correct stale blocking/threshold claims and synchronize the project instructions with the current UI architecture and matching retail binary.

Add copyright-safe packet and DAT-container fixtures plus a failing installed-DAT conformance audit for projectile shapes, typed tables, recall motion, default scripts, and raw CreateBlocking inventory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 22:40:42 +02:00
parent bb5acab9e6
commit d53fe30ffe
14 changed files with 1545 additions and 53 deletions

View file

@ -78,19 +78,17 @@ and `~/.claude/projects/.../memory/` (the latter is browsable in
Obsidian via the `claude-memory/` junction in the repo root; see Obsidian via the `claude-memory/` junction in the repo root; see
`memory/reference_obsidian_vault.md`). `memory/reference_obsidian_vault.md`).
**UI strategy:** three-layer split — swappable backend (ImGui.NET + **UI strategy:** two coexisting presentation stacks over shared state,
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a, custom retail-look ViewModels, events, and commands. ImGui.NET +
toolkit for D.2b later) / stable `AcDream.UI.Abstractions` layer `Silk.NET.OpenGL.Extensions.ImGui` is the permanent
(ViewModels + Commands + `IPanel` / `IPanelRenderer`) / unchanged game `ACDREAM_DEVTOOLS=1` developer stack using `IPanel`/`IPanelRenderer`.
state. **As of Phase I, ImGui hosts every dev/debug panel** — Vitals, Retail gameplay UI is the independent retained `UiHost`/`UiRoot` tree in
Chat, Debug. The previous custom-StbTrueTypeSharp `DebugOverlay` was `AcDream.App/UI`, imported from LayoutDesc/DAT assets and bound by focused
deleted in I.2; `TextRenderer` + `BitmapFont` are kept alive controllers. The stable cross-stack seam is ViewModels/commands, not a backend
specifically for the future world-space HUD (D.6 — damage floaters, swap. `TextRenderer` + `BitmapFont` also serve D.6 world-space HUD elements
name plates) where ImGui can't reach into the 3D scene. D.2b remains where ImGui cannot reach the 3D scene. Plugin gameplay UI uses the BCL-only
the long-term retail-look path (panels reskinned one at a time using `AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel` contract; plugins
dat assets); ImGui persists forever as the `ACDREAM_DEVTOOLS=1` never import App or ImGui namespaces. Full design:
overlay. **All plugin-facing UI targets `AcDream.UI.Abstractions`
never import a backend namespace from a panel.** Full design:
[`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md). [`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md).
Memory cribs: `memory/project_chat_pipeline.md` (chat pipeline as of Memory cribs: `memory/project_chat_pipeline.md` (chat pipeline as of
Phase I), `memory/project_input_pipeline.md` (input pipeline as of Phase I), `memory/project_input_pipeline.md` (input pipeline as of
@ -497,10 +495,13 @@ guessing has failed twice in a row.**
### What we have ### What we have
- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe` - **Matching binary**: `C:\Users\erikn\Downloads\acclient.exe`
v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`, v11.4186 (linker timestamp `2013-09-06 00:17:56 UTC`,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs
exactly with our `refs/acclient.pdb`. exactly with our `refs/acclient.pdb`. The current
`C:\Turbine\Asheron's Call\acclient.exe` is a 2015 build with GUID
`08e25c14-e2a1-46d5-b056-92b2e43a7234` and must not be used for
this PDB/address map.
- **Debugger**: `cdb.exe` (console WinDbg) at - **Debugger**: `cdb.exe` (console WinDbg) at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
Install via Microsoft Store WinDbg (~50 MB). 32-bit version is Install via Microsoft Store WinDbg (~50 MB). 32-bit version is
@ -519,7 +520,7 @@ guessing has failed twice in a row.**
1. **Verify the binary matches the PDB:** 1. **Verify the binary matches the PDB:**
```bash ```bash
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe" py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"
``` ```
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===` Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`

View file

@ -493,10 +493,13 @@ guessing has failed twice in a row.**
### What we have ### What we have
- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe` - **Matching binary**: `C:\Users\erikn\Downloads\acclient.exe`
v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`, v11.4186 (linker timestamp `2013-09-06 00:17:56 UTC`,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs
exactly with our `refs/acclient.pdb`. exactly with our `refs/acclient.pdb`. The current
`C:\Turbine\Asheron's Call\acclient.exe` is a 2015 build with GUID
`08e25c14-e2a1-46d5-b056-92b2e43a7234` and must not be used for
this PDB/address map.
- **Debugger**: `cdb.exe` (console WinDbg) at - **Debugger**: `cdb.exe` (console WinDbg) at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
Install via Microsoft Store WinDbg (~50 MB). 32-bit version is Install via Microsoft Store WinDbg (~50 MB). 32-bit version is
@ -515,7 +518,7 @@ guessing has failed twice in a row.**
1. **Verify the binary matches the PDB:** 1. **Verify the binary matches the PDB:**
```bash ```bash
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe" py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"
``` ```
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===` Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`

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` consumed as a no-op; no sequencer implements the pause retail performs (consistent with the missing pending_motions chain, 2026-06-04 deep-dive) | `src/AcDream.Core/Vfx/ParticleHookSink.cs:112` | Responsibility assigned to the (future) sequencer layer when the sink was written | Animations retail pauses on a particle (cast/effect beats) run straight through — visual beat desynced from the effect | retail sequencer blocking-particle handling (r04 §6) | | 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-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

@ -69,7 +69,13 @@ Both carry a single `uint EmitterId` (lines 27-30 of respective generated files)
### `CreateBlockingParticleHook` ### `CreateBlockingParticleHook`
Source: `Generated/Types/CreateBlockingParticleHook.generated.cs:22-37`**empty body** in the dat. The "blocking" variant is a runtime behavior flag, not a data field. The generated package type has an empty body, but that is a schema defect, not
the retail DAT layout. Retail `CreateBlockingParticleHook::Execute`
(`0x00526EF0`) reads the complete inherited `CreateParticleHook` payload:
emitter DID, part index, full offset frame, and logical emitter ID. At runtime
`ParticleManager::CreateBlockingParticleEmitter` (`0x0051B8A0`) suppresses the
create only while the same nonzero logical emitter ID is already live. It does
not pause animation progression.
### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`) ### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`)
@ -105,11 +111,14 @@ Source: `Generated/DBObjs/PhysicsScriptTable.generated.cs:22-59`.
```csharp ```csharp
Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable; Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable;
// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values) // PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values)
// PhysicsScriptTableData = List<ScriptAndModData> Scripts (weighted variants) // PhysicsScriptTableData = List<ScriptAndModData> Scripts (stored-order thresholds)
// ScriptAndModData = { float Mod; QualifiedDataId<PhysicsScript> ScriptId; } // ScriptAndModData = { float Mod; QualifiedDataId<PhysicsScript> ScriptId; }
``` ```
Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Enables "when I die, pick a death-sound script with weight = Mod". Not relevant for sky, but relevant for NPC/monster/spell PES. Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Retail walks entries in
stored DAT order and returns the first whose `intensity <= Mod`; `Mod` is an
upper threshold, not a random weight. Equality matches, and there is no
sorting or fallback-to-first. This is relevant for NPC/monster/spell PES.
### Retail factory registration (chunk_00410000.c:13439-13451) ### Retail factory registration (chunk_00410000.c:13439-13451)

View file

@ -264,7 +264,9 @@ CreateParticleHook:
parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id) parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id)
CreateBlockingParticleHook: CreateBlockingParticleHook:
same particle creation path, plus sequencer blocking semantics decode the same payload as CreateParticleHook
if emitter_id is nonzero and still live: suppress this creation
otherwise create normally; do not pause animation
DestroyParticleHook: DestroyParticleHook:
parent.destroy_particle_emitter(emitter_id) parent.destroy_particle_emitter(emitter_id)

View file

@ -0,0 +1,649 @@
# Retail projectile, PhysicsScript, and portal-effect pseudocode
**Date:** 2026-07-13
**Build:** Sept 2013 EoR retail client, matching named PDB/decomp
**Purpose:** Oracle for the acdream inbound missile and DAT-driven VFX port.
This note translates the named retail functions into implementation-oriented
pseudocode before any C# port. Addresses refer to
`docs/research/named-retail/acclient_2013_pseudo_c.txt`. Retail remains the
behavioral oracle; ACE, ACViewer, and Holtburger are interpretation and wire
cross-checks only.
## 1. PhysicsDesc wire order
Oracle: `PhysicsDesc::UnPack` at `0x0051DDD0`.
```text
unpack PhysicsDesc:
destroy prior owned buffers
bitfield = read u32
state = read u32
if bitfield has Movement (0x00010000):
length = read u32
movementBytes = read length bytes
if length > 0: autonomousMovement = read u32
else if bitfield has AnimationFrame (0x00020000):
animationFrame = read u32
if Position (0x00008000): position = unpack Position
if MTable (0x00000002): motionTableId = read DID
if STable (0x00000800): soundTableId = read DID
if PeTable (0x00001000): physicsScriptTableId = read DID
if CSetup (0x00000001): setupId = read DID
if Parent (0x00000020): parentGuid, locationId = read u32, u32
if Children (0x00000040):
count = read u32
repeat count: childGuid, childLocationId = read u32, u32
if ObjScale (0x00000080): objectScale = read f32
if Friction (0x00000100): friction = read f32
if Elasticity (0x00000200): elasticity = read f32
if Translucency (0x00040000): translucency = read f32
if Velocity (0x00000004): velocity = read vec3 f32
if Acceleration (0x00000008): acceleration = read vec3 f32
if Omega (0x00000010): omega = read vec3 f32
if DefaultScript (0x00002000): defaultScriptType = read u32
if DefaultScriptIntensity (0x00004000): defaultIntensity = read f32
timestamps = read exactly nine u16 values in this order:
Position, Movement, State, Vector, Teleport,
ServerControlledMove, ForcePosition, ObjDesc, Instance
align cursor to four bytes
```
Absent optional values are semantically different from present zero values and
must remain nullable in the parsed representation. ACE's
`PhysicsDescriptionFlag`, and Holtburger's object-description reader, confirm
the same order and nine-value timestamp tail.
## 2. Installing a PhysicsDesc
Oracle: `CPhysicsObj::set_description` at `0x00514F40` and
`CPhysicsObj::InitDefaults` at `0x005139D0`.
```text
InitDefaults(setup):
if setup.DefaultScript != 0: play that direct PhysicsScript DID
if setup.DefaultMotionTable != 0: install it
if setup.DefaultSoundTable != 0: replace sound table
if setup.DefaultPhysicsScriptTable != 0: replace typed-effect table
if object is static:
set HasDefaultAnim / HasDefaultScript as appropriate
register static animation when either applies
set_description(desc):
install motion table
release current sound table
if desc.SoundTable != 0: load it
release current PhysicsScriptTable unconditionally
if desc.PeTable != 0: load it
install movement buffer or placement frame
set_state(desc.State, applySideEffects=true)
install scale, valid friction, elasticity, translucency
set_velocity(desc.Velocity)
install desc.Omega
install typed default-script type and intensity
copy all nine timestamps into the object's update-time array
```
The network PeTable therefore overrides the Setup table even when the network
value is absent/zero: a live object does not fall back to the Setup table after
`set_description`. The network acceleration vector is unpacked but is not
installed here; retail recalculates normal acceleration from physics state.
## 3. PhysicsState values and state transitions
The values cross-check exactly with ACE's `PhysicsState` enum:
```text
Static=0x1, Ethereal=0x4, ReportCollisions=0x8,
IgnoreCollisions=0x10, NoDraw=0x20, Missile=0x40,
Pushable=0x80, AlignPath=0x100, PathClipped=0x200,
Gravity=0x400, Lighting=0x800, ParticleEmitter=0x1000,
Hidden=0x4000, ScriptedCollision=0x8000,
HasPhysicsBsp=0x10000, Inelastic=0x20000,
HasDefaultAnim=0x40000, HasDefaultScript=0x80000,
Cloaked=0x100000, ReportAsEnvironment=0x200000,
EdgeSlide=0x400000, Sledding=0x800000, Frozen=0x1000000
```
Oracle: `CPhysicsObj::set_state` at `0x00514DD0` and
`CPhysicsObj::set_hidden` at `0x00514C60`.
```text
set_state(next):
changed = current XOR next
current = next
if Lighting changed:
initialize or destroy part-array lights
if NoDraw changed:
apply set_nodraw before Hidden handling
if Hidden changed:
set_hidden(next has Hidden)
set_hidden(true):
set Hidden
resolve PS_Hidden at intensity 1 through current table; enqueue it
set every attached child NoDraw and update its part array
if ReportCollisions: end reporting and clear the bit
set IgnoreCollisions
hide root object in its cell/render world
set_hidden(false):
clear Hidden
resolve PS_UnHide at intensity 1 through current table; enqueue it
clear every attached child's NoDraw, unconditionally as retail does
clear IgnoreCollisions
if ReportCollisions is clear: set it and start reporting
enter/unhide root part array and cell object
```
`CPhysicsObj::CPhysicsObj` at `0x005123F0` initializes state to
`0x00400C08` (the assignment is at `0x00512508`). That value is
`EdgeSlide | Lighting | Gravity | ReportCollisions`; it does **not** contain
`Hidden` (`0x00004000`). Consequently, an ordinary initially-visible
CreateObject does not call `set_hidden` and must not manufacture an UnHide
script. UnHide occurs only for a real state-bit transition from Hidden to
visible, while an explicit F755 UnHide remains a separate typed effect packet.
Hidden is never object destruction: scripts and particles continue to exist
while the mesh and interaction surface are suppressed.
`SmartBox::DoSetState` at `0x004520D0` applies the state only when its u16
timestamp is newer using the retail wrap-safe comparison.
The same comparator is used by State, Vector, Movement, Position, and related
timestamp-gated channels (`SmartBox::DoVectorUpdate` at `0x004521C0` is the
second executable cross-check):
```text
is_newer(incoming:u16, stored:u16):
distance = abs((int)incoming - (int)stored)
if distance > 0x7FFF:
return incoming < stored
return stored < incoming
```
Equality is stale. Up through a distance of `0x7FFF`, the numerically higher
stamp wins. At exactly `0x8000` and throughout the wrap half, the numerically
lower stamp wins. This exact boundary must be ported; a generic half-range
subtraction helper can choose the opposite result at `0x8000`.
## 4. Direct and typed effect packets
Oracles:
- `SmartBox::HandlePlayScriptID` at `0x00452020`
- `SmartBox::HandlePlayScriptType` at `0x00452070`
```text
F754 direct packet: targetGuid:u32, physicsScriptDid:u32
F755 typed packet: targetGuid:u32, scriptType:u32, intensity:f32
on either packet:
if target object is not materialized:
QueueBlobForObject(targetGuid, whole packet) and return queued
if F754: play direct PhysicsScript DID
if F755: resolve type+intensity through target's current table, then play
```
The queue preserves packet order. F754 never goes through the table. F755 must
preserve unknown raw type values and the incoming float bits until resolution.
ACE's `GameMessageScript` confirms the 16-byte opcode+GUID+type+float F755 wire
shape.
Synthetic, non-DAT packet fixtures fixed for the parser port are:
```text
F754: 54 F7 00 00 | 42 00 00 50 | 99 00 00 33
opcode GUID 50000042 script DID 33000099
F755: 55 F7 00 00 | 42 00 00 50 | A5 A5 A5 A5 | 34 12 C0 7F
opcode GUID 50000042 unknown raw type NaN payload bits
```
Step 1 consumes these in parser tests so exact packet lengths and raw float
bits are exercised. Step 3 constructs synthetic raw Animation and
PhysicsScript fixtures through the compatibility-reader tests; no copyrighted
installed-DAT bytes are committed.
## 5. PhysicsScriptTable selection
Oracles:
- `PhysicsScriptTable::GetScript` at `0x00521E40`
- `PhysicsScriptTableData::GetScript` at `0x00521A20`
```text
resolve(type, intensity):
data = hash lookup by exact type
if absent: invalid DID
for entry in stored DAT order:
if intensity <= entry.Mod:
return entry.ScriptId
return invalid DID
```
`Mod` is an upper threshold, not a random weight. Do not sort entries and do
not fall back to the first. Equality matches. Matching x86 at `0x00521A3B`
uses `test ah, 0x41; jnp return`: one tested status bit means intensity is less
than the entry (C0) or equal to it (C3), while unordered/NaN sets both tested
bits and continues. NaN therefore resolves to no script; an intensity above
every stored threshold also resolves to no script. Consequently negative
infinity selects the first finite threshold, while positive infinity and NaN
select none; the resolver must preserve and compare the incoming float rather
than blanket-rejecting all non-finite values.
## 6. ScriptManager queue
Oracles:
- `ScriptManager::AddScriptInternal` at `0x0051B310`
- `ScriptManager::NextHook` at `0x0051B3F0`
- `ScriptManager::UpdateScripts` at `0x0051B480`
```text
add(script):
node = new ScriptData
node.start = queue empty
? Timer.current
: tail.start + tail.script.length
append node, including duplicate script/owner pairs
if it became the head:
hookIndex = -1
nextHookTime = node.start + firstHook.start
update:
while a current node exists and currentTime >= nextHookTime:
hook = NextHook()
if hook exists:
execute hook against owner object
else:
pop and release completed head
initialize next node or mark queue empty
```
Each owner has one serial FIFO, not a deduplicated set or a concurrent list.
Different owners have independent queues. A long frame executes all hooks that
became due, in order.
## 7. CallPES and default-script hooks
Oracle: `CPhysicsObj::CallPES` at `0x00511AF0`.
```text
CallPES(scriptDid, maximumPause):
if maximumPause < 0.0002 seconds:
if object is in a cell: enqueue script immediately
else:
delay = uniformRandom(0, maximumPause)
insert a timed CALL_PES object hook at the head of the object-hook list
when due, enqueue the PhysicsScript on this owner's FIFO
```
The timed object-hook list is traversed head-to-next. Multiple delayed CallPES
hooks are therefore observed newest-first until their individually randomized
times become due; this is separate from the PhysicsScript owner's serial FIFO.
`DefaultScriptHook` resolves the owner's PhysicsDesc default type/intensity.
`DefaultScriptPartHook` locates the child attached at the requested parent part
and resolves the child's own table/default, not the parent's.
## 8. Particle emitter semantics
Oracles:
- `CreateBlockingParticleHook::Execute` at `0x00526EF0`
- `ParticleManager::CreateBlockingParticleEmitter` at `0x0051B8A0`
CreateBlockingParticle inherits the complete CreateParticle payload:
```text
emitterInfoDid, partIndex, offsetFrame(position+orientation), logicalEmitterId
```
```text
normal CreateParticle:
nonzero logical id replaces the existing live emitter with that id
zero id creates a new anonymous emitter
blocking CreateParticle:
if nonzero logical id names a live emitter: do nothing and return 0
otherwise create exactly as normal
```
"Blocking" does not pause the animation sequencer. The current
Chorizite.DatReaderWriter model omits this inherited payload and can misalign
the following hook, so acdream needs one centralized raw-hook compatibility
reader for PhysicsScript and Animation files. Ordinary hook types remain
delegated to the package decoder.
The complete offset Frame must be decoded and retained for schema fidelity,
but retail particle emission consumes only its origin. `Particle::Init` at
`0x0051C930` adds that origin to the particle start point and transforms the
translation plus emitter vectors through the current owner part/root frame; it
does not read the hook-offset quaternion. `ParticleEmitter::UpdateParticles`
at `0x0051D180` then chooses either the saved start frame or the current live
part/root frame according to the DAT parenting mode.
Emitter attachment is therefore evaluated as:
```text
ownerFrame = partIndex == -1 ? rootWorld : animatedPartLocal * rootWorld
anchorPosition = transformPoint(hookOffset.origin, ownerFrame)
```
The hook-offset quaternion is not applied to particle emission unless a
different retail hook consumer proves otherwise. For non-parent-local
particles, only future emissions follow a moving anchor; already-born
particles remain in world space. Parent-local particles continue to follow
their owner.
## 9. Per-frame physics and projectile flags
Oracles:
- `CPhysicsObj::UpdatePositionInternal` at `0x00512C30`
- `CPhysicsObj::UpdateObjectInternal` at `0x005156B0`
- `CPhysicsObj::update_object` at `0x00515D10`
- `CPhysicsObj::UpdatePhysicsInternal` at `0x00510700`
- `CPhysicsObj::calc_acceleration` at `0x00510950`
- `CPhysicsObj::get_object_info` at `0x00511CC0`
- `Frame::set_vector_heading` at `0x00535DB0`
```text
update_object:
if parented, out of world, or Frozen:
clear transient Active and return without an object tick
elapsed = Timer.current - update_time
PhysicsTimer.current = update_time
if elapsed <= 0.0002 seconds:
update_time = Timer.current
return
if elapsed > 2 seconds:
update_time = Timer.current
return
while elapsed > MAX_QUANTUM (1/5 second):
PhysicsTimer.current += MAX_QUANTUM
UpdateObjectInternal(MAX_QUANTUM)
elapsed -= MAX_QUANTUM
if elapsed > MIN_QUANTUM (1/30 second):
PhysicsTimer.current += elapsed
UpdateObjectInternal(elapsed)
update_time = PhysicsTimer.current
UpdateObjectInternal(dt):
UpdatePositionInternal(dt, candidateFrame)
if Setup has one or more physics spheres:
align path when applicable
sweep/transition from committed frame to candidate frame
commit transition result and update children
else:
commit candidate frame directly; do not create a dummy-sphere sweep
update detection/target/movement/position managers
ParticleManager.UpdateParticles()
ScriptManager.UpdateScripts()
UpdatePositionInternal(dt, candidateFrame):
unless Hidden:
advance part array / animation and produce its frame offset
apply PositionManager offset even when Hidden
combine object root frame with animation offset
unless Hidden: UpdatePhysicsInternal(dt, candidateFrame)
process timed object hooks even when Hidden
UpdatePhysicsInternal(dt, frame):
speedSquared = dot(velocity, velocity)
if speedSquared > 0:
if speedSquared > 50^2: normalize velocity and clamp to 50
calculate friction using dt and the clamped pre-friction speedSquared
if speedSquared < (0.25^2 + 0.0002): zero velocity
frame.position += velocity*dt + 0.5*acceleration*dt^2
velocity += acceleration*dt
frame.rotation = globalRotate(frame.rotation, omega*dt)
calc_acceleration:
if transient has Contact AND OnWalkable AND state lacks Sledding:
acceleration=0 and omega=0
else if Gravity is clear: acceleration=0 (omega is preserved)
else acceleration=(0,0,PhysicsGlobals.gravity)
```
The `Sledding` constant (`0x00800000`) in `calc_acceleration` is confirmed from
matching-binary disassembly at `0x00510961`; the named lift misidentified it as
a string address. The near-stop test is deliberately based on the original or
50-unit-clamped `speedSquared`, not a speed recomputed after friction.
The same matching executable resolves the `update_object` globals that the
named lift displays as zero: `MAX_QUANTUM` is 0.2 seconds and `MIN_QUANTUM` is
1/30 second. Thus a long but valid frame is replayed as 0.2-second catch-up
steps, a final remainder at or below 1/30 second stays accumulated for the next
tick, and a gap above 2 seconds is discarded instead of simulating a huge
projectile jump.
AlignPath uses the full normalized 3D movement vector through
`Frame::set_vector_heading`; it is not yaw-only.
`get_object_info` adds transition flag `0x8` when PhysicsState.Missile is set.
That is PathClipped behavior. It does not add PerfectClip, so ordinary missiles
must not implicitly arm PerfectClip.
## 10. Missile collision exclusion
Oracle: `OBJECTINFO::missile_ignore` at `0x0050CEB0`.
```text
missile_ignore(other):
if other is a missile: ignore
if moving object is not a missile: do not ignore
if other.id == designatedTargetId: do not ignore
if other is ethereal and has a weenie: ignore
if designatedTargetId != 0 and other is a creature: ignore
otherwise collide
```
The moving object's target ID initializes to zero. The client must not infer it
from the currently selected UI target.
Retail builds the transition sweep from Setup physics spheres when present,
scaled by object scale. Although the lower-level `transition` routine has a
dummy-sphere path, ordinary `UpdateObjectInternal` does not call `transition`
when `GetNumSphere() == 0`; it commits the frame without a collision sweep.
Projectile shape selection must therefore come from Setup data, not a
mesh-size guess or a synthesized dummy radius.
## 11. Installed-DAT and fixture audit
The installed DAT files at `C:\Turbine\Asheron's Call` are private
interoperability inputs and are not copied into the repository. The audit is
reproducible with:
```powershell
dotnet run --project tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj -c Release
```
The command is a conformance gate: missing required assets, changed hashes or
sphere shapes, parse failures, a changed recall-motion closure, or a changed raw
CreateBlocking inventory produce a nonzero exit code.
The projectile WCID mapping comes from the local ACE weenie corpus at
`C:\Users\erikn\source\repos\acdream\references\weenies(single file).json`
by selecting WCIDs 300, 305, 7276-7282, and 23144 and reading DID properties
Setup (1) and PhysicsEffectTable (22).
| WCID | Name | Setup | PhysicsScriptTable |
|---:|---|---:|---:|
| 300 | Arrow | `0x02000124` | `0x3400002B` |
| 305 | Quarrel / crossbow bolt | `0x0200012A` | `0x3400002B` |
| 7276 | Acid Stream | `0x020003F6` | `0x34000082` |
| 7277 | Flame Bolt | `0x0200040D` | `0x34000005` |
| 7278 | Force Bolt | `0x0200087D` | none in template |
| 7279 | Frost Bolt | `0x020003F4` | `0x34000080` |
| 7280 | Lightning Bolt | `0x02000880` | `0x34000081` |
| 7281 | Shockwave | `0x020003FA` | none in template |
| 7282 | Whirling Blade | `0x020003FC` | none in template |
| 23144 | Tusker Fist | `0x02000EAE` | none in template |
### 11.1 Collision shapes
Every audited projectile Setup has exactly one ordinary sphere and no cylinder
spheres. A sphere has a Setup-local origin and radius, not an orientation.
| Setups | Sphere origin | Radius (world units) |
|---|---:|---:|
| Arrow, quarrel, acid, flame, frost, shockwave, whirling blade, Tusker Fist | `(0, 0, 0)` | `0.10` |
| Lightning Bolt `0x02000880` | `(0, 0, 0)` | `0.15` |
| Force Bolt `0x0200087D` | `(0, -0.165, 0.1045)` | `0.102` |
This pins a one-sphere invariant for the required projectile set, but disproves
a centered-sphere shortcut: Force Bolt's sphere is offset. Step 6 must preserve
the Setup-local origin and multiply both origin and radius by object scale.
### 11.2 Projectile table order and chains
The relevant entries, in stored order, are:
- Arrow/quarrel table `0x3400002B`: Create `(1 -> 0x33000719)`,
Destroy `(1 -> 0x33000233)`, and DisappearDestroy
`(1 -> 0x33000302)`. It has no Launch or Explode entry.
- Flame table `0x34000005`: ProjectileCollision
`(1 -> 0x33000109)`; Launch
`(0 -> 0x3300011B, 0.5 -> 0x3300011C, 1 -> 0x3300011D)`;
Explode
`(0 -> 0x3300011E, 0.5 -> 0x3300012B, 1 -> 0x33000120)`.
- Frost `0x34000080`: collision `0x33000109`, Launch `0x330008BF`,
Explode `0x33000119`, each at threshold 1.
- Lightning `0x34000081`: collision `0x33000109`, Launch `0x330008C0`,
Explode `0x3300013E`, each at threshold 1.
- Acid `0x34000082`: collision `0x33000109`, Launch `0x330008C2`,
Explode `0x33000132`, each at threshold 1.
- Tusker Fist has no network/template table, but its Setup `0x02000EAE`
carries direct `DefaultScript=0x33000D77`. Setup initialization therefore
runs that direct PES: Scale, CreateParticle emitter `0x3200071B` on part 0,
and SoundTable, all at time zero.
The Launch scripts contain CreateParticle trails plus sound-table hooks; the
collision script contains SetLight; Explode scripts contain particles and
sound. This confirms that physical and spell missiles use the same generic
PhysicsScript/hook sinks rather than a special render-only trail path.
### 11.3 Recall and Hidden/UnHide
`0x10000153` is the ACE/local-DAT motion command, not an Animation DID. In the
human MotionTable `0x09000001`, the NonCombat link resolves it to Animation
`0x030009BF`, frames `0..end` at `9.96` frames/second. At frame 16 that
Animation directly creates emitter `0x3200078E`, attached to root part `-1`
with logical ID 19 and zero offset, then plays SoundTweaked hooks at frames 16,
46, 76, and 116. There is no CallPES in this human recall chain. Other local
creature MotionTables resolve the same command to `0x03000047`, `0x0300098F`,
or a `0x03000C34 -> 0x030009BF -> 0x03000C34` sequence.
Installed tables containing both typed transitions group into two asset pairs:
- Tables `0x34000004`, `0x34000064`, `0x34000066`, `0x34000076`,
`0x34000095`, `0x340000B2`, `0x340000B4`, `0x340000C9`,
`0x340000CA`, `0x340000CB`, `0x340000CD`, and `0x340000D1`: UnHide
`(1 -> 0x3300032F)`, Hidden
`(1 -> 0x33000331)`.
- Tables `0x34000063`, `0x340000A9`, `0x340000B9`, and `0x340000BB`: UnHide
`(1 -> 0x330002E0)`, Hidden `(1 -> 0x330002F3)`.
This installed metadata also validates the table comparison recovered from
x86: state transitions call intensity 1 and the entries have Mod 1, so equality
must match.
### 11.4 CreateBlocking and fixture provenance
An aligned raw retail-layout scan finds CreateBlocking hooks in PhysicsScripts
`0x33000AEA` through `0x33000AF8` inclusive and none in Animations. The scan
validates the type, a retail direction value, and the `0x32xxxxxx` emitter DID
prefix directly from raw bytes, so the defective stock decoder cannot skip or
misalign a following hook. The repository commits only synthetic,
noncopyrighted fixtures:
- `tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs`:
exact F754/F755, a broad projectile-field CreateObject, isolated Movement
and AnimationFrame branches, and absent-versus-present-zero PeTable shapes.
Step 1 adds truncation at each gated cursor.
- `tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs`: a synthetic raw
PhysicsScript and Animation container, each containing a complete
CreateBlocking payload followed by an AnimationDone hook at the exact
expected cursor.
Representative installed raw-entry identities are pinned as metadata, not
copied bytes:
| DID | Length | SHA-256 |
|---:|---:|---|
| Arrow Setup `0x02000124` | 812 | `630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E` |
| Quarrel Setup `0x0200012A` | 776 | `ED529E834CC1799B5655549200F7D1060FB62A707988E7B7D4FBED2D9C4BA0D1` |
| Force Bolt Setup `0x0200087D` | 344 | `B30469764673F55BCBB4F123559256299C34D819974EAB42AC476E3FEED6EAE8` |
| Flame table `0x34000005` | 88 | `86906B0EDDC9AA2242091EAC7A22C88FB2150F0773B6CCA1FA5E4AAEAFF2A386` |
| Recall Animation `0x030009BF` | 143560 | `21EF99FE5A760216AF48946B9C41D0FA7E4B94297B8DEC8B91C41BB8C7A0D02D` |
| Blocking PES `0x33000AEA` | 64 | `C0D75973ADB55386ED6AC860C6D08EC43862ABE8CE07AC4E942EBA31DAF61320` |
| Tusker Fist default PES `0x33000D77` | 108 | `9F27628BDEA6B05C95CFADD037746AB380542DF8B040265EF881B3366CB8E12E` |
ACE's current `SpellProjectile.Setup` cross-check confirms that server-created
spell projectiles set ReportCollisions, Missile, AlignPath, and PathClipped;
rotating projectile definitions instead set omega and clear AlignPath. On
impact ACE broadcasts authoritative SetState, typed effect scripts, velocity
zero, and eventual deletion. The client therefore predicts presentation but
does not invent impact or destruction.
The recall Animation's direct DAT hooks must be executed rather than replaced
with a guessed cloud. Hidden/UnHide state transitions and explicit typed
scripts remain distinct packet-driven events. A normal visible spawn alone is
not evidence for an UnHide materialization effect; the observer portal-in
sequence must be validated against its actual inbound state/effect packets
during Step 8.
## 12. Secondary-reference cross-checks
The following secondary implementations were inspected after the named-retail
oracle. They confirm wire/schema or server-authority details; none overrides a
retail disagreement:
- ACE `Source/ACE.Entity/Enum/PhysicsDescriptionFlag.cs` and
`Source/ACE.Entity/Enum/PhysicsState.cs` confirm PhysicsDesc gates and state
bits.
- ACE `Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs`
confirms F755's opcode/GUID/raw type/float layout.
- ACE `Source/ACE.Server/WorldObjects/SpellProjectile.cs` confirms the server
creates missile bodies and remains authoritative for impact state, effect,
vector correction, and deletion.
- Holtburger
`crates/holtburger-protocol/src/messages/object/messages/description.rs`
confirms PhysicsDesc field order and the nine trailing sequences.
- ACViewer `ACViewer/Entity/AnimationHooks/CreateParticleHook.cs` and
`ACViewer/Physics/Scripts/PhysicsScriptTableData.cs` were used as schema
interpretation aids only; their behavior is not substituted for retail's
blocking-emitter or stored-order threshold rules.
Repository links:
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Entity/Enum/PhysicsDescriptionFlag.cs>
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Entity/Enum/PhysicsState.cs>
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs>
- <https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/WorldObjects/SpellProjectile.cs>
- <https://github.com/merklejerk/holtburger/blob/main/crates/holtburger-protocol/src/messages/object/messages/description.rs>
- <https://github.com/ACEmulator/ACViewer/blob/master/ACViewer/Entity/AnimationHooks/CreateParticleHook.cs>
- <https://github.com/ACEmulator/ACViewer/blob/master/ACViewer/Physics/Scripts/PhysicsScriptTableData.cs>
## 13. Port invariants
1. Server GUID is translated once to a canonical local entity ID before an
effect reaches render, audio, light, translucency, or particle sinks.
2. Logical entity creation/destruction is separate from spatial rebucketing.
3. Server Position/Vector/State/Delete packets remain authoritative.
4. No client-side impact, explosion, damage, or deletion is synthesized.
5. Per-object frame ordering preserves retail: animation/timed object hooks,
movement commit and child updates, particle tick, then PhysicsScript tick.
A script-created emitter begins particle simulation on the following tick.
6. Hidden suppresses mesh/collision/interaction but does not destroy the effect
owner.
7. DAT assets determine models, colors, timing, and attachment behavior.
8. Every ported branch receives a conformance test and a named-retail citation.

View file

@ -516,7 +516,7 @@ per-part transforms. Each frame can carry zero or many hooks. The full
| `0x17` | TextureVelocity | Scrolling texture (water) | | `0x17` | TextureVelocity | Scrolling texture (water) |
| `0x18` | TextureVelocityPart | per-part | | `0x18` | TextureVelocityPart | per-part |
| `0x19` | SetLight | Dynamic light on/off | | `0x19` | SetLight | Dynamic light on/off |
| `0x1A` | CreateBlockingParticle | Same as CreateParticle but blocks anim progression until done | | `0x1A` | CreateBlockingParticle | Same payload as CreateParticle; suppresses creation while the same nonzero logical emitter ID remains live. It does not pause animation. |
### 5.2 Firing semantics ### 5.2 Firing semantics

View file

@ -190,7 +190,7 @@ PhysicsScriptData {
| Hook | Fields | Role | | Hook | Fields | Role |
|------|--------|------| |------|--------|------|
| `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part | | `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part |
| `CreateBlockingParticleHook` | — | Identical to CreateParticle but blocks motion-table advance until the emitter retires (used to gate spell cast sequences) | | `CreateBlockingParticleHook` | Same fields as CreateParticle | Suppresses creation while the same nonzero logical emitter ID remains live; it does not pause motion-table advance |
| `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) | | `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) |
| `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles | | `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles |
| `DefaultScriptHook` | — | Used inside animation tables; implicit | | `DefaultScriptHook` | — | Used inside animation tables; implicit |
@ -211,7 +211,7 @@ PhysicsScriptTable {
} }
PhysicsScriptTableData { PhysicsScriptTableData {
List<ScriptAndModData> Scripts // sorted by Mod descending List<ScriptAndModData> Scripts // preserve stored DAT order
} }
ScriptAndModData { ScriptAndModData {
@ -221,12 +221,11 @@ ScriptAndModData {
``` ```
The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients. The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients.
The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the Retail scans the stored `Scripts` entries and returns the first entry for which
incoming `mod`. That's how a Frost I missile and a Frost VII missile can the incoming intensity is less than or equal to `Mod`. Equality matches; the
share a `PlayScript.Launch` entry but show different-intensity effects: the client neither sorts the entries nor falls back to the first. ACViewer's
spell sets mod=0.3 vs mod=0.95, and the table picks the matching `ParticleViewer.GetModIdx()` is only an interpretation aid where it differs
`PhysicsScript` DID. This is the dispatch lookup done by from `PhysicsScriptTableData::GetScript` (`0x00521A20`).
`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`).
### PlayScript enum — the well-known effect namespace ### PlayScript enum — the well-known effect namespace
@ -460,14 +459,14 @@ mapping is setup-specific; for humanoids the well-known indices are:
### Offset Frame ### Offset Frame
`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied `CreateParticleHook.Offset` is encoded as a complete `Frame` (position plus
inside the parent-part's local space. For a torch: `PartIndex=0` (torch quaternion), but retail `Particle::Init` (`0x0051C930`) consumes only its
mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the origin for particle emission. For a torch: `PartIndex=0` (torch mesh),
torch regardless of how the torch is held. The frame is stored in dat `Offset.Origin=(0, 0, 0.45)` spawns the fire emitter at the top of the torch
coordinates; our port multiplies it into the part-local transform with regardless of how the torch is held. Retail transforms that origin and the
standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) * emitter's own vectors through the current parent-part/root frame; it does not
Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's apply `Offset.Orientation`. The quaternion is still decoded and retained for
world matrix each frame. schema fidelity, not multiplied into the particle anchor.
### Retail `set_parent` call ### Retail `set_parent` call

View file

@ -22,10 +22,11 @@ namespace AcDream.Core.Vfx;
/// renderer exposes per-part world transforms. /// renderer exposes per-part world transforms.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <see cref="CreateBlockingParticleHook"/> — same as /// <see cref="CreateBlockingParticleHook"/> — has the same payload
/// CreateParticleHook but the retail sequencer blocks animation /// as CreateParticleHook, but suppresses creation while the same nonzero
/// progression until the particle is done. We spawn the emitter /// logical emitter ID is live. It does not pause animation. The dependency
/// identically; the "blocking" semantics belong to the sequencer. /// currently omits that inherited payload; the retail compatibility reader
/// owns its decoding.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter /// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
@ -110,11 +111,11 @@ public sealed class ParticleHookSink : IAnimationHookSink
break; break;
case CreateBlockingParticleHook: case CreateBlockingParticleHook:
// Retail's CreateBlockingParticleHook is a sentinel (no // Retail gives this the full CreateParticle payload and
// payload) — the sequencer pauses advancement until the // suppresses creation only while the same nonzero logical ID
// current particle emitter finishes. We surface this as // is live; it never pauses animation. The dependency loses
// "no spawn needed, just keep the current particle alive"; // that payload, so TS-11 remains a no-op until the centralized
// blocking semantics live in the sequencer, not here. // raw-hook compatibility reader supplies the retail shape.
break; break;
case DestroyParticleHook dph: case DestroyParticleHook dph:

View file

@ -0,0 +1,135 @@
using System.Buffers.Binary;
namespace AcDream.Content.Tests.Vfx;
/// <summary>
/// Synthetic raw DAT fixtures captured from the retail hook schema audit.
/// They contain no installed Turbine DAT bytes. Step 3 consumes the blocking
/// hook followed by an ordinary hook to prove cursor alignment.
/// </summary>
internal static class ProjectileVfxDatFixtures
{
internal static readonly byte[] PhysicsScriptCreateBlockingThenAnimationDone = BuildPhysicsScript();
internal static readonly byte[] AnimationCreateBlockingThenAnimationDone = BuildAnimation();
private static byte[] BuildPhysicsScript()
{
var bytes = new byte[80];
int pos = 0;
WriteU32(0x3300F001u); // synthetic PhysicsScript DID
WriteU32(2); // two PhysicsScriptData entries
WriteF64(0.0);
WriteU32(0x1Au); // CreateBlockingParticle
WriteU32(0); // AnimationHookDir.Both
WriteU32(0x3200F001u); // emitter DID
WriteU32(0xFFFFFFFFu); // root attachment, distinct from part zero
WriteF32(1.0f); WriteF32(2.0f); WriteF32(3.0f); // offset origin
WriteF32(0.5f); WriteF32(0.5f); WriteF32(-0.5f); WriteF32(-0.5f); // WXYZ quaternion
WriteU32(7); // nonzero logical emitter ID
WriteF64(1.25);
WriteU32(0x04u); // AnimationDone: proves the next cursor
WriteU32(1); // AnimationHookDir.Forward
if (pos != bytes.Length) throw new InvalidOperationException($"Fixture length mismatch: {pos}");
return bytes;
void WriteU32(uint value)
{
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(pos), value);
pos += 4;
}
void WriteF32(float value)
{
BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(pos), value);
pos += 4;
}
void WriteF64(double value)
{
BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(pos), value);
pos += 8;
}
}
private static byte[] BuildAnimation()
{
// Animation header: DID, flags, NumParts=0, NumFrames=1. The sole
// AnimationFrame then has hookCount=2 and the same two hook records as
// the PhysicsScript fixture. No position or part frames are present.
var bytes = new byte[76];
int pos = 0;
WriteU32(0x0300F001u); // synthetic Animation DID
WriteU32(0); // no PosFrames
WriteU32(0); // NumParts
WriteU32(1); // NumFrames
WriteU32(2); // AnimationFrame hook count
WriteU32(0x1Au); // CreateBlockingParticle
WriteU32(0); // AnimationHookDir.Both
WriteU32(0x3200F002u); // emitter DID
WriteU32(0); // part zero, distinct from the PES root fixture
WriteF32(-1.0f); WriteF32(4.0f); WriteF32(0.25f); // offset origin
WriteF32(1.0f); WriteF32(0.0f); WriteF32(0.0f); WriteF32(0.0f); // WXYZ identity
WriteU32(9); // nonzero logical emitter ID
WriteU32(0x04u); // AnimationDone at the exact next cursor
WriteU32(0xFFFFFFFFu); // AnimationHookDir.Backward
if (pos != bytes.Length) throw new InvalidOperationException($"Fixture length mismatch: {pos}");
return bytes;
void WriteU32(uint value)
{
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(pos), value);
pos += 4;
}
void WriteF32(float value)
{
BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(pos), value);
pos += 4;
}
}
}
public sealed class ProjectileVfxDatFixtureTests
{
[Fact]
public void BlockingThenOrdinaryHook_PinsInheritedPayloadAndNextCursor()
{
ReadOnlySpan<byte> bytes = ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone;
Assert.Equal(80, bytes.Length);
Assert.Equal(0x3300F001u, BinaryPrimitives.ReadUInt32LittleEndian(bytes));
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[4..]));
Assert.Equal(0x1Au, BinaryPrimitives.ReadUInt32LittleEndian(bytes[16..]));
Assert.Equal(0x3200F001u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[24..]));
Assert.Equal(0xFFFFFFFFu, BinaryPrimitives.ReadUInt32LittleEndian(bytes[28..]));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[60..]));
Assert.Equal(1.25, BinaryPrimitives.ReadDoubleLittleEndian(bytes[64..]));
Assert.Equal(0x04u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[72..]));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[76..]));
}
[Fact]
public void AnimationContainer_BlockingThenOrdinaryHookPinsNextCursor()
{
ReadOnlySpan<byte> bytes = ProjectileVfxDatFixtures.AnimationCreateBlockingThenAnimationDone;
Assert.Equal(76, bytes.Length);
Assert.Equal(0x0300F001u, BinaryPrimitives.ReadUInt32LittleEndian(bytes));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[8..]));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[12..]));
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[16..]));
Assert.Equal(0x1Au, BinaryPrimitives.ReadUInt32LittleEndian(bytes[20..]));
Assert.Equal(0x3200F002u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[28..]));
Assert.Equal(9u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[64..]));
Assert.Equal(0x04u, BinaryPrimitives.ReadUInt32LittleEndian(bytes[68..]));
Assert.Equal(0xFFFFFFFFu, BinaryPrimitives.ReadUInt32LittleEndian(bytes[72..]));
}
}

View file

@ -0,0 +1,283 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Synthetic, non-DAT packet fixtures captured by the Step 0 retail wire
/// audit. Parser conformance tests consume these from Step 1 onward.
/// </summary>
internal static class ProjectileVfxPacketFixtures
{
internal static readonly byte[] DirectF754 =
[
0x54, 0xF7, 0x00, 0x00,
0x42, 0x00, 0x00, 0x50,
0x99, 0x00, 0x00, 0x33,
];
internal static readonly byte[] TypedF755UnknownNaN =
[
0x55, 0xF7, 0x00, 0x00,
0x42, 0x00, 0x00, 0x50,
0xA5, 0xA5, 0xA5, 0xA5,
0x34, 0x12, 0xC0, 0x7F,
];
internal static readonly byte[] GoldenCreateObjectProjectilePhysicsFields = BuildGoldenCreateObject();
internal static readonly byte[] CreateObjectMovementPresentEmpty = BuildBranchFixture(
CreateObject.PhysicsDescriptionFlag.Movement, 0x50000051u);
internal static readonly byte[] CreateObjectAnimationFramePresentZero = BuildBranchFixture(
CreateObject.PhysicsDescriptionFlag.AnimationFrame, 0x50000052u);
internal static readonly byte[] CreateObjectPeTablePresentZero = BuildBranchFixture(
CreateObject.PhysicsDescriptionFlag.PeTable, 0x50000053u);
internal static readonly byte[] CreateObjectPeTableAbsent = BuildBranchFixture(
CreateObject.PhysicsDescriptionFlag.None, 0x50000054u);
private static byte[] BuildGoldenCreateObject()
{
var bytes = new List<byte>();
uint flags =
(uint)(CreateObject.PhysicsDescriptionFlag.CSetup
| CreateObject.PhysicsDescriptionFlag.MTable
| CreateObject.PhysicsDescriptionFlag.Velocity
| CreateObject.PhysicsDescriptionFlag.Acceleration
| CreateObject.PhysicsDescriptionFlag.Omega
| CreateObject.PhysicsDescriptionFlag.Parent
| CreateObject.PhysicsDescriptionFlag.Children
| CreateObject.PhysicsDescriptionFlag.ObjScale
| CreateObject.PhysicsDescriptionFlag.Friction
| CreateObject.PhysicsDescriptionFlag.Elasticity
| CreateObject.PhysicsDescriptionFlag.STable
| CreateObject.PhysicsDescriptionFlag.PeTable
| CreateObject.PhysicsDescriptionFlag.DefaultScript
| CreateObject.PhysicsDescriptionFlag.DefaultScriptIntensity
| CreateObject.PhysicsDescriptionFlag.Position
| CreateObject.PhysicsDescriptionFlag.Translucency);
WriteU32(CreateObject.Opcode);
WriteU32(0x50000042u);
bytes.AddRange([0x11, 0, 0, 0]); // empty ModelData
WriteU32(flags);
WriteU32(0x00000748u); // Missile | ReportCollisions | AlignPath | PathClipped | Gravity
WriteU32(0x7F010123u); // full cell
WriteF32(12.5f); WriteF32(25.0f); WriteF32(3.75f);
WriteF32(1.0f); WriteF32(0.0f); WriteF32(0.0f); WriteF32(0.0f); // WXYZ
WriteU32(0x09000001u);
WriteU32(0x20000014u);
WriteU32(0x34000005u);
WriteU32(0x0200040Du);
WriteU32(0x50000010u); WriteU32(3u);
WriteU32(1u); WriteU32(0x50000011u); WriteU32(4u);
WriteF32(1.25f);
WriteF32(0.125f);
WriteF32(0.05f);
WriteF32(0.25f);
WriteF32(10f); WriteF32(20f); WriteF32(30f);
WriteF32(1f); WriteF32(2f); WriteF32(3f);
WriteF32(0.1f); WriteF32(0.2f); WriteF32(0.3f);
WriteU32(0xA5A5A5A5u);
WriteU32(0x7FC01234u); // NaN intensity, preserving payload bits
for (ushort i = 0; i < 9; i++) WriteU16((ushort)(0xFFF8u + i));
Align4();
WriteU32(0u); // no optional WeenieHeader fields
WriteString16L("Golden Projectile");
WritePackedDword(7277u);
WritePackedDword(0u);
WriteU32(0x00000100u); // ammunition ItemType
WriteU32(0u);
Align4();
return bytes.ToArray();
void WriteU16(ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
bytes.AddRange(buffer.ToArray());
}
void WriteU32(uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
bytes.AddRange(buffer.ToArray());
}
void WriteF32(float value) => WriteU32(BitConverter.SingleToUInt32Bits(value));
void WritePackedDword(uint value)
{
if (value <= 0x7FFFu) WriteU16((ushort)value);
else
{
WriteU16((ushort)(((value >> 16) & 0x7FFFu) | 0x8000u));
WriteU16((ushort)value);
}
}
void WriteString16L(string value)
{
byte[] encoded = System.Text.Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4();
}
void Align4()
{
while ((bytes.Count & 3) != 0) bytes.Add(0);
}
}
/// <summary>
/// ACE SerializePhysicsData-shaped fixture with synthetic values. These
/// small packets isolate mutually-exclusive and present-zero branches that
/// a single broad CreateObject cannot cover.
/// </summary>
private static byte[] BuildBranchFixture(CreateObject.PhysicsDescriptionFlag flags, uint guid)
{
var bytes = new List<byte>();
WriteU32(CreateObject.Opcode);
WriteU32(guid);
bytes.AddRange([0x11, 0, 0, 0]); // empty ModelData
WriteU32((uint)flags);
WriteU32(0); // explicit zero PhysicsState
if ((flags & CreateObject.PhysicsDescriptionFlag.Movement) != 0)
WriteU32(0); // present Movement with an empty byte blob; no autonomous dword
else if ((flags & CreateObject.PhysicsDescriptionFlag.AnimationFrame) != 0)
WriteU32(0); // present placement/frame with value zero
if ((flags & CreateObject.PhysicsDescriptionFlag.PeTable) != 0)
WriteU32(0); // present PhysicsScriptTable DID with value zero
for (ushort i = 0; i < 9; i++) WriteU16((ushort)(0x1100 + i));
Align4();
WriteU32(0); // no optional WeenieHeader fields
WriteString16L("Physics Branch Fixture");
WritePackedDword(7277);
WritePackedDword(0);
WriteU32(0x00000100u);
WriteU32(0);
Align4();
return bytes.ToArray();
void WriteU16(ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
bytes.AddRange(buffer.ToArray());
}
void WriteU32(uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
bytes.AddRange(buffer.ToArray());
}
void WritePackedDword(uint value)
{
if (value <= 0x7FFFu) WriteU16((ushort)value);
else
{
WriteU16((ushort)(((value >> 16) & 0x7FFFu) | 0x8000u));
WriteU16((ushort)value);
}
}
void WriteString16L(string value)
{
byte[] encoded = System.Text.Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4();
}
void Align4()
{
while ((bytes.Count & 3) != 0) bytes.Add(0);
}
}
}
public sealed class ProjectileVfxPacketFixtureTests
{
[Fact]
public void DirectF754_PreservesExactTwelveByteWireShape()
{
ReadOnlySpan<byte> packet = ProjectileVfxPacketFixtures.DirectF754;
Assert.Equal(12, packet.Length);
Assert.Equal(0xF754u, BinaryPrimitives.ReadUInt32LittleEndian(packet));
Assert.Equal(0x50000042u, BinaryPrimitives.ReadUInt32LittleEndian(packet[4..]));
Assert.Equal(0x33000099u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
}
[Fact]
public void TypedF755_PreservesUnknownTypeAndNaNPayloadBits()
{
ReadOnlySpan<byte> packet = ProjectileVfxPacketFixtures.TypedF755UnknownNaN;
Assert.Equal(16, packet.Length);
Assert.Equal(0xF755u, BinaryPrimitives.ReadUInt32LittleEndian(packet));
Assert.Equal(0x50000042u, BinaryPrimitives.ReadUInt32LittleEndian(packet[4..]));
Assert.Equal(0xA5A5A5A5u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
Assert.Equal(0x7FC01234u, BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]));
Assert.True(float.IsNaN(BinaryPrimitives.ReadSingleLittleEndian(packet[12..])));
}
[Fact]
public void GoldenCreateObject_ExercisesBroadProjectilePhysicsFieldsWithoutLosingAlignment()
{
byte[] packet = ProjectileVfxPacketFixtures.GoldenCreateObjectProjectilePhysicsFields;
var parsed = CreateObject.TryParse(packet);
Assert.NotNull(parsed);
Assert.Equal(0x50000042u, parsed.Value.Guid);
Assert.Equal("Golden Projectile", parsed.Value.Name);
Assert.Equal(0x0200040Du, parsed.Value.SetupTableId);
Assert.Equal(0x09000001u, parsed.Value.MotionTableId);
Assert.Equal(1.25f, parsed.Value.ObjScale);
Assert.Equal(0.125f, parsed.Value.Friction);
Assert.Equal(0.05f, parsed.Value.Elasticity);
Assert.Equal(0x50000010u, parsed.Value.ParentGuid);
Assert.Equal((ushort)0xFFF8, parsed.Value.PositionSequence);
Assert.Equal((ushort)0xFFF9, parsed.Value.MovementSequence);
Assert.Equal((ushort)0x0000, parsed.Value.InstanceSequence);
}
[Fact]
public void MutuallyExclusiveMovementAndAnimationFrameFixturesRemainAligned()
{
var movement = CreateObject.TryParse(ProjectileVfxPacketFixtures.CreateObjectMovementPresentEmpty);
var animation = CreateObject.TryParse(ProjectileVfxPacketFixtures.CreateObjectAnimationFramePresentZero);
Assert.NotNull(movement);
Assert.NotNull(animation);
Assert.Equal(0x50000051u, movement.Value.Guid);
Assert.Equal(0x50000052u, animation.Value.Guid);
Assert.Null(movement.Value.PlacementId);
Assert.Equal(0u, animation.Value.PlacementId);
Assert.Equal((ushort)0x1108, movement.Value.InstanceSequence);
Assert.Equal((ushort)0x1108, animation.Value.InstanceSequence);
}
[Fact]
public void PeTableFixturesPinAbsentVersusPresentZeroWireShapes()
{
ReadOnlySpan<byte> present = ProjectileVfxPacketFixtures.CreateObjectPeTablePresentZero;
ReadOnlySpan<byte> absent = ProjectileVfxPacketFixtures.CreateObjectPeTableAbsent;
Assert.Equal((uint)CreateObject.PhysicsDescriptionFlag.PeTable,
BinaryPrimitives.ReadUInt32LittleEndian(present[12..]));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(present[20..]));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(absent[12..]));
Assert.Equal(present.Length, absent.Length + sizeof(uint));
Assert.NotNull(CreateObject.TryParse(present));
Assert.NotNull(CreateObject.TryParse(absent));
}
}

View file

@ -58,7 +58,7 @@ foreach (var root in roots)
DumpEmitter(eid, pad + " ", rootForReport); DumpEmitter(eid, pad + " ", rootForReport);
break; break;
case CreateBlockingParticleHook _: case CreateBlockingParticleHook _:
Console.WriteLine($"{head} (no payload — looks at next hook)"); Console.WriteLine($"{head} (package schema omits retail's inherited CreateParticle payload)");
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

@ -0,0 +1,399 @@
using System.Collections;
using System.Buffers.Binary;
using System.Numerics;
using System.Reflection;
using System.Security.Cryptography;
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;
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? @"C:\Turbine\Asheron's Call";
using var dats = new DatCollection(datDir, DatAccessType.Read);
Console.WriteLine($"DAT metadata audit: {datDir}");
var failures = new List<string>();
var representativePes = new HashSet<uint>();
var expectedSetups = new Dictionary<uint, (Vector3 Origin, float Radius, int Length, string Sha256)>
{
[0x02000124u] = (Vector3.Zero, 0.1f, 812, "630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E"),
[0x0200012Au] = (Vector3.Zero, 0.1f, 776, "ED529E834CC1799B5655549200F7D1060FB62A707988E7B7D4FBED2D9C4BA0D1"),
[0x020003F6u] = (Vector3.Zero, 0.1f, 212, "E211E9C0736797D7003CEC029E8978AE7E5BE0AFBEB76811ECB15DC2A637C0A1"),
[0x0200040Du] = (Vector3.Zero, 0.1f, 212, "671BEAA3BEFB1B2A91C102CCD722F3A5843FF39BB83800C5EDE4460DFE062572"),
[0x0200087Du] = (new Vector3(0, -0.165f, 0.1045f), 0.102f, 344, "B30469764673F55BCBB4F123559256299C34D819974EAB42AC476E3FEED6EAE8"),
[0x020003F4u] = (Vector3.Zero, 0.1f, 212, "9BE00CC9844BF8912CE90AD34EA3214095FE4B29C9425AFEE4EFE70302B50FCD"),
[0x02000880u] = (Vector3.Zero, 0.15f, 212, "E25C47ED5EF850128D9114982CED5DA27ABA8343AEEE7D491B499AD69A04ABAC"),
[0x020003FAu] = (Vector3.Zero, 0.1f, 320, "BFC0C65BE287708DAFE1BF0645737FC1B49A32502987768573F8314FA8B8F905"),
[0x020003FCu] = (Vector3.Zero, 0.1f, 320, "387312DD1B71FC9B4631DA065BCBDC4D51C790A106DDA5631FF0F13C52158E3B"),
[0x02000EAEu] = (Vector3.Zero, 0.1f, 320, "B16B316E6F142A5B37D8B07D2DE1C42CBD12C7D74E23E5D916B5323A99854104"),
};
var expectedPhysicsScripts = new Dictionary<uint, (int Length, string Sha256)>
{
[0x33000D77u] = (108, "9F27628BDEA6B05C95CFADD037746AB380542DF8B040265EF881B3366CB8E12E"),
};
var expectedRecallAnimations = new Dictionary<uint, (int Length, string Sha256)>
{
[0x03000047u] = (28224, "FC01ADE01EAAFE4B276685D4927FDCEE53492F1AF30E84A6DD497E8A74343995"),
[0x0300098Fu] = (104680, "77D1A958941B7A31EA885F790986C59F8148E7CAC7C5C8DDF16AE461C39E5BAD"),
[0x030009BFu] = (143560, "21EF99FE5A760216AF48946B9C41D0FA7E4B94297B8DEC8B91C41BB8C7A0D02D"),
[0x03000C34u] = (9576, "FBE05A10C00005BB44ED890A31519E6186F5C5E800780769EFE481BE74BB507B"),
};
uint[] setupIds =
[
0x02000124u, // Arrow
0x0200012Au, // Quarrel / crossbow bolt
0x020003F6u, // Acid Stream
0x0200040Du, // Flame Bolt
0x0200087Du, // Force Bolt
0x020003F4u, // Frost Bolt
0x02000880u, // Lightning Bolt
0x020003FAu, // Shockwave
0x020003FCu, // Whirling Blade
0x02000EAEu, // Tusker Fist
];
Console.WriteLine("\n=== projectile Setup collision metadata ===");
foreach (uint id in setupIds)
{
Setup? setup = SafeGet<Setup>(id);
if (setup is null)
{
Console.WriteLine($"0x{id:X8}: missing");
failures.Add($"required Setup 0x{id:X8} is missing");
continue;
}
Console.WriteLine($"0x{id:X8}: spheres={setup.Spheres.Count}, cylSpheres={setup.CylSpheres.Count}, "
+ $"radius={setup.Radius:R}, height={setup.Height:R}, defaultPES=0x{setup.DefaultScript.DataId:X8}, "
+ $"defaultPETable=0x{(uint)setup.DefaultScriptTable:X8}");
for (int i = 0; i < setup.Spheres.Count; i++)
{
var s = setup.Spheres[i];
Console.WriteLine($" sphere[{i}] origin=({s.Origin.X:R},{s.Origin.Y:R},{s.Origin.Z:R}) radius={s.Radius:R}");
}
for (int i = 0; i < setup.CylSpheres.Count; i++)
{
var s = setup.CylSpheres[i];
Console.WriteLine($" cylinder[{i}] origin=({s.Origin.X:R},{s.Origin.Y:R},{s.Origin.Z:R}) radius={s.Radius:R} height={s.Height:R}");
}
var expected = expectedSetups[id];
if (setup.Spheres.Count != 1 || setup.CylSpheres.Count != 0)
failures.Add($"Setup 0x{id:X8} expected one ordinary sphere and no cylinder spheres");
else if (!NearlyEqualVector(setup.Spheres[0].Origin, expected.Origin) || !NearlyEqualFloat(setup.Spheres[0].Radius, expected.Radius))
failures.Add($"Setup 0x{id:X8} sphere changed: got {setup.Spheres[0].Origin}/{setup.Spheres[0].Radius:R}");
if ((uint)setup.DefaultScript.DataId != 0)
representativePes.Add((uint)setup.DefaultScript.DataId);
PrintRawMetadata(id, expected.Length, expected.Sha256);
}
uint[] tableIds =
[
0x3400002Bu, // arrow/quarrel template
0x34000005u, // flame bolt
0x34000080u, // frost bolt
0x34000081u, // lightning bolt
0x34000082u, // acid stream
];
Console.WriteLine("\n=== representative PhysicsScriptTables (stored order) ===");
var expectedTables = new Dictionary<uint, (int Length, string Sha256)>
{
[0x3400002Bu] = (504, "C97123E43657C6C04812FB9B04C893DB057069695748DCA5F7F71F2575E30F05"),
[0x34000005u] = (88, "86906B0EDDC9AA2242091EAC7A22C88FB2150F0773B6CCA1FA5E4AAEAFF2A386"),
[0x34000080u] = (56, "BF84C0A7C19821D4B9282D0A37F46B9C87E4B59FC9AD29263F7F6DDF5C0F65DA"),
[0x34000081u] = (56, "D65B0B64E1EEA997C7B6AF3DA99A050D32F10738A1393207FB933AC7AE112DFA"),
[0x34000082u] = (56, "BDF28D167425C3835CD9FBE8556D414D3E58A477A7F840D6DC89072676A39A56"),
};
foreach (uint id in tableIds)
{
PhysicsScriptTable? table = SafeGet<PhysicsScriptTable>(id);
Console.WriteLine($"TABLE 0x{id:X8}");
if (table is null)
{
Console.WriteLine(" missing");
failures.Add($"required PhysicsScriptTable 0x{id:X8} is missing");
}
else
{
DumpObject(table, " ", depth: 0, maxDepth: 5);
foreach (var data in table.ScriptTable.Values)
foreach (var entry in data.Scripts)
representativePes.Add((uint)entry.ScriptId);
}
var expected = expectedTables[id];
PrintRawMetadata(id, expected.Length, expected.Sha256);
}
Console.WriteLine("\n=== representative projectile PhysicsScript hooks ===");
var representativeVisited = new HashSet<uint>();
foreach (uint pes in representativePes.Order())
DumpPhysicsScript(pes, representativeVisited, "");
Console.WriteLine("\n=== installed Hidden / UnHide table entries ===");
foreach (uint id in dats.GetAllIdsOfType<PhysicsScriptTable>())
{
PhysicsScriptTable? table = SafeGet<PhysicsScriptTable>(id);
if (table is null) continue;
foreach (var pair in table.ScriptTable)
{
string type = pair.Key.ToString();
if (type is not "Hidden" and not "UnHide") continue;
Console.WriteLine($"TABLE 0x{id:X8} {type}");
DumpObject(pair.Value, " ", depth: 0, maxDepth: 3);
}
}
const int RecallCommand = unchecked((int)0x10000153u);
var recallAnimationIds = new HashSet<uint>();
Console.WriteLine("\n=== local-DAT LifestoneRecall 0x10000153 motion chains ===");
foreach (uint id in dats.GetAllIdsOfType<MotionTable>())
{
MotionTable? table = SafeGet<MotionTable>(id);
if (table is null) continue;
foreach (var outer in table.Links)
{
if (!outer.Value.MotionData.TryGetValue(RecallCommand, out var motionData)) continue;
Console.WriteLine($"MotionTable 0x{id:X8}, link=0x{unchecked((uint)outer.Key):X8}");
CollectAnimationIds(motionData, recallAnimationIds, " ");
}
if (table.Modifiers.TryGetValue(RecallCommand, out var modifier))
{
Console.WriteLine($"MotionTable 0x{id:X8}, modifier");
CollectAnimationIds(modifier, recallAnimationIds, " ");
}
}
Console.WriteLine("\n=== recall Animation hooks and reachable PES roots ===");
var reachablePes = new HashSet<uint>();
foreach (uint expectedId in expectedRecallAnimations.Keys)
if (!recallAnimationIds.Contains(expectedId))
failures.Add($"expected recall Animation 0x{expectedId:X8} was not resolved from motion 0x10000153");
foreach (uint animationId in recallAnimationIds.Order())
{
DumpAnimation(animationId, reachablePes);
if (expectedRecallAnimations.TryGetValue(animationId, out var expected))
PrintRawMetadata(animationId, expected.Length, expected.Sha256);
else
failures.Add($"unmanifested recall Animation 0x{animationId:X8}");
}
Console.WriteLine("\n=== reachable recall PhysicsScripts ===");
var visitedPes = new HashSet<uint>();
foreach (uint pes in reachablePes.Order())
DumpPhysicsScript(pes, visitedPes, "");
Console.WriteLine("\n=== installed CreateBlockingParticle occurrences (raw retail-layout scan) ===");
var blockingPes = FindRawBlockingHooks(dats.GetAllIdsOfType<DatPhysicsScript>());
Console.WriteLine($"PhysicsScripts: {string.Join(", ", blockingPes.Select(x => $"0x{x:X8}"))}");
foreach (uint id in blockingPes) PrintRawMetadata(id);
var blockingAnimations = FindRawBlockingHooks(dats.GetAllIdsOfType<DatAnimation>());
Console.WriteLine($"Animations: {string.Join(", ", blockingAnimations.Select(x => $"0x{x:X8}"))}");
uint[] expectedBlockingPes = Enumerable.Range(0xAEA, 0xF).Select(x => 0x33000000u | (uint)x).ToArray();
if (!blockingPes.SequenceEqual(expectedBlockingPes))
failures.Add($"raw blocking-PES inventory changed: got {string.Join(",", blockingPes.Select(x => $"0x{x:X8}"))}");
if (blockingAnimations.Count != 0)
failures.Add($"expected no raw blocking Animation hooks, got {string.Join(",", blockingAnimations.Select(x => $"0x{x:X8}"))}");
Console.WriteLine();
if (failures.Count == 0)
{
Console.WriteLine("AUDIT PASS: required assets, hashes, shapes, and raw blocking-hook inventory match.");
}
else
{
Console.Error.WriteLine("AUDIT FAILED:");
foreach (string failure in failures.Distinct()) Console.Error.WriteLine($" - {failure}");
System.Environment.ExitCode = 1;
}
return;
T? SafeGet<T>(uint id) where T : class, IDBObj
{
try { return dats.Get<T>(id); }
catch (Exception ex)
{
Console.WriteLine($" parse failure {typeof(T).Name} 0x{id:X8}: {ex.GetType().Name}: {ex.Message}");
failures.Add($"failed to parse {typeof(T).Name} 0x{id:X8}: {ex.GetType().Name}");
return null;
}
}
void PrintRawMetadata(uint id, int? expectedLength = null, string? expectedSha256 = null)
{
try
{
if (!dats.Portal.TryGetFileBytes(id, out byte[]? bytes, true) || bytes is null)
{
Console.WriteLine($" raw 0x{id:X8}: unavailable");
failures.Add($"raw entry 0x{id:X8} is unavailable");
return;
}
string sha256 = Convert.ToHexString(SHA256.HashData(bytes));
Console.WriteLine($" raw 0x{id:X8}: length={bytes.Length}, sha256={sha256}");
if (expectedLength.HasValue && bytes.Length != expectedLength.Value)
failures.Add($"raw entry 0x{id:X8} length changed: expected {expectedLength}, got {bytes.Length}");
if (expectedSha256 is not null && !sha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
failures.Add($"raw entry 0x{id:X8} hash changed: expected {expectedSha256}, got {sha256}");
}
catch (Exception ex)
{
Console.WriteLine($" raw 0x{id:X8}: {ex.GetType().Name}: {ex.Message}");
failures.Add($"failed to read raw entry 0x{id:X8}: {ex.GetType().Name}");
}
}
List<uint> FindRawBlockingHooks(IEnumerable<uint> ids)
{
var matches = new List<uint>();
foreach (uint id in ids.Order())
{
if (!dats.Portal.TryGetFileBytes(id, out byte[]? bytes, true) || bytes is null)
{
failures.Add($"raw hook scan could not read 0x{id:X8}");
continue;
}
// Retail CreateBlockingParticle is a 48-byte CreateParticle-shaped
// hook: type, direction, 0x32xxxxxx emitter DID, part, Frame, logical
// ID. Scan aligned raw entries so the defective package decoder cannot
// skip or misalign a following hook. The DID prefix plus a valid retail
// direction makes accidental payload matches deterministically rejectable.
for (int offset = 4; offset <= bytes.Length - 48; offset += 4)
{
if (BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset)) != 0x1Au)
continue;
uint direction = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 4));
if (direction is not (0u or 1u or 0xFFFFFFFEu or 0xFFFFFFFFu))
continue;
uint emitterDid = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 8));
if ((emitterDid & 0xFF000000u) != 0x32000000u)
continue;
matches.Add(id);
break;
}
}
return matches;
}
static bool NearlyEqualVector(Vector3 left, Vector3 right)
=> Vector3.DistanceSquared(left, right) <= 1e-10f;
static bool NearlyEqualFloat(float left, float right)
=> MathF.Abs(left - right) <= 1e-6f;
static void CollectAnimationIds(MotionData motion, HashSet<uint> ids, string indent)
{
foreach (AnimData anim in motion.Anims)
{
uint id = (uint)anim.AnimId;
ids.Add(id);
Console.WriteLine($"{indent}Animation 0x{id:X8}, frames={anim.LowFrame}..{anim.HighFrame}, rate={anim.Framerate:R}");
}
}
void DumpAnimation(uint id, HashSet<uint> pesRoots)
{
DatAnimation? animation = SafeGet<DatAnimation>(id);
if (animation is null) return;
Console.WriteLine($"Animation 0x{id:X8}: frames={animation.PartFrames.Count}");
for (int frameIndex = 0; frameIndex < animation.PartFrames.Count; frameIndex++)
{
foreach (AnimationHook hook in animation.PartFrames[frameIndex].Hooks)
{
Console.WriteLine($" frame[{frameIndex}] {DescribeHook(hook)}");
if (hook is CallPESHook call) pesRoots.Add(call.PES);
}
}
}
void DumpPhysicsScript(uint id, HashSet<uint> visited, string indent)
{
if (!visited.Add(id)) return;
DatPhysicsScript? script = SafeGet<DatPhysicsScript>(id);
if (script is null) return;
Console.WriteLine($"{indent}PES 0x{id:X8}: hooks={script.ScriptData.Count}");
if (expectedPhysicsScripts.TryGetValue(id, out var expected))
PrintRawMetadata(id, expected.Length, expected.Sha256);
else
PrintRawMetadata(id);
foreach (PhysicsScriptData entry in script.ScriptData)
{
Console.WriteLine($"{indent} t={entry.StartTime:R} {DescribeHook(entry.Hook)}");
if (entry.Hook is CallPESHook call)
DumpPhysicsScript(call.PES, visited, indent + " ");
}
}
static string DescribeHook(AnimationHook hook) => hook switch
{
CallPESHook call => $"CallPES PES=0x{call.PES:X8} pause={call.Pause:R}",
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}) "
+ $"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)",
_ => hook.HookType.ToString(),
};
static void DumpObject(object? value, string indent, int depth, int maxDepth)
{
if (value is null) { Console.WriteLine(indent + "null"); return; }
if (depth >= maxDepth || value is string || value.GetType().IsPrimitive || value is decimal || value.GetType().IsEnum)
{
Console.WriteLine(indent + FormatScalar(value));
return;
}
if (value is IDictionary dictionary)
{
foreach (DictionaryEntry item in dictionary)
{
Console.WriteLine($"{indent}[{FormatScalar(item.Key)}]");
DumpObject(item.Value, indent + " ", depth + 1, maxDepth);
}
return;
}
if (value is IEnumerable sequence)
{
int index = 0;
foreach (object? item in sequence)
{
Console.WriteLine($"{indent}[{index++}]");
DumpObject(item, indent + " ", depth + 1, maxDepth);
}
return;
}
Type type = value.GetType();
Console.WriteLine($"{indent}{type.Name}");
foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
{
Console.Write($"{indent} {field.Name} = ");
DumpObject(field.GetValue(value), indent + " ", depth + 1, maxDepth);
}
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(x => x.GetIndexParameters().Length == 0 && x.Name is not "DBObjType" and not "HeaderFlags"))
{
Console.Write($"{indent} {property.Name} = ");
DumpObject(property.GetValue(value), indent + " ", depth + 1, maxDepth);
}
}
static string FormatScalar(object? value) => value switch
{
null => "null",
uint v => $"0x{v:X8}",
int v => $"0x{unchecked((uint)v):X8}",
_ => value.ToString() ?? "",
};

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
</ItemGroup>
</Project>