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:
parent
bb5acab9e6
commit
d53fe30ffe
14 changed files with 1545 additions and 53 deletions
35
AGENTS.md
35
AGENTS.md
|
|
@ -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 ===`
|
||||||
|
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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 ===`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
649
docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
Normal file
649
docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
135
tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs
Normal file
135
tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs
Normal 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..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
399
tools/ProjectileVfxAudit/Program.cs
Normal file
399
tools/ProjectileVfxAudit/Program.cs
Normal 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() ?? "",
|
||||||
|
};
|
||||||
11
tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj
Normal file
11
tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue