diff --git a/AGENTS.md b/AGENTS.md index c753cbd1..e061aa2e 100644 --- a/AGENTS.md +++ b/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 `memory/reference_obsidian_vault.md`). -**UI strategy:** three-layer split — swappable backend (ImGui.NET + -`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a, custom retail-look -toolkit for D.2b later) / stable `AcDream.UI.Abstractions` layer -(ViewModels + Commands + `IPanel` / `IPanelRenderer`) / unchanged game -state. **As of Phase I, ImGui hosts every dev/debug panel** — Vitals, -Chat, Debug. The previous custom-StbTrueTypeSharp `DebugOverlay` was -deleted in I.2; `TextRenderer` + `BitmapFont` are kept alive -specifically for the future world-space HUD (D.6 — damage floaters, -name plates) where ImGui can't reach into the 3D scene. D.2b remains -the long-term retail-look path (panels reskinned one at a time using -dat assets); ImGui persists forever as the `ACDREAM_DEVTOOLS=1` -overlay. **All plugin-facing UI targets `AcDream.UI.Abstractions` — -never import a backend namespace from a panel.** Full design: +**UI strategy:** two coexisting presentation stacks over shared state, +ViewModels, events, and commands. ImGui.NET + +`Silk.NET.OpenGL.Extensions.ImGui` is the permanent +`ACDREAM_DEVTOOLS=1` developer stack using `IPanel`/`IPanelRenderer`. +Retail gameplay UI is the independent retained `UiHost`/`UiRoot` tree in +`AcDream.App/UI`, imported from LayoutDesc/DAT assets and bound by focused +controllers. The stable cross-stack seam is ViewModels/commands, not a backend +swap. `TextRenderer` + `BitmapFont` also serve D.6 world-space HUD elements +where ImGui cannot reach the 3D scene. Plugin gameplay UI uses the BCL-only +`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel` contract; plugins +never import App or ImGui namespaces. Full design: [`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 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 -- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe` - v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`, +- **Matching binary**: `C:\Users\erikn\Downloads\acclient.exe` + v11.4186 (linker timestamp `2013-09-06 00:17:56 UTC`, 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 `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. 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:** ```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 ===` diff --git a/CLAUDE.md b/CLAUDE.md index 004860ac..a7cac5e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -493,10 +493,13 @@ guessing has failed twice in a row.** ### What we have -- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe` - v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`, +- **Matching binary**: `C:\Users\erikn\Downloads\acclient.exe` + v11.4186 (linker timestamp `2013-09-06 00:17:56 UTC`, 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 `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. 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:** ```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 ===` diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9e484c19..91c706b9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-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 | diff --git a/docs/research/2026-04-23-physicsscript.md b/docs/research/2026-04-23-physicsscript.md index b258ec7e..8e633e39 100644 --- a/docs/research/2026-04-23-physicsscript.md +++ b/docs/research/2026-04-23-physicsscript.md @@ -69,7 +69,13 @@ Both carry a single `uint EmitterId` (lines 27-30 of respective generated files) ### `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`) @@ -105,11 +111,14 @@ Source: `Generated/DBObjs/PhysicsScriptTable.generated.cs:22-59`. ```csharp Dictionary ScriptTable; // PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values) -// PhysicsScriptTableData = List Scripts (weighted variants) +// PhysicsScriptTableData = List Scripts (stored-order thresholds) // ScriptAndModData = { float Mod; QualifiedDataId 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) diff --git a/docs/research/2026-04-28-pes-pseudocode.md b/docs/research/2026-04-28-pes-pseudocode.md index ecf7f876..a4dd4b7e 100644 --- a/docs/research/2026-04-28-pes-pseudocode.md +++ b/docs/research/2026-04-28-pes-pseudocode.md @@ -264,7 +264,9 @@ CreateParticleHook: parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id) 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: parent.destroy_particle_emitter(emitter_id) diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md new file mode 100644 index 00000000..be1bb3bd --- /dev/null +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -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: + +- +- +- +- +- +- +- + +## 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. diff --git a/docs/research/deepdives/r03-motion-animation.md b/docs/research/deepdives/r03-motion-animation.md index 8e0b2198..342fab5f 100644 --- a/docs/research/deepdives/r03-motion-animation.md +++ b/docs/research/deepdives/r03-motion-animation.md @@ -516,7 +516,7 @@ per-part transforms. Each frame can carry zero or many hooks. The full | `0x17` | TextureVelocity | Scrolling texture (water) | | `0x18` | TextureVelocityPart | per-part | | `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 diff --git a/docs/research/deepdives/r04-vfx-particles.md b/docs/research/deepdives/r04-vfx-particles.md index c2f2153c..2b21e2d9 100644 --- a/docs/research/deepdives/r04-vfx-particles.md +++ b/docs/research/deepdives/r04-vfx-particles.md @@ -190,7 +190,7 @@ PhysicsScriptData { | Hook | Fields | Role | |------|--------|------| | `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) | | `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles | | `DefaultScriptHook` | — | Used inside animation tables; implicit | @@ -211,7 +211,7 @@ PhysicsScriptTable { } PhysicsScriptTableData { - List Scripts // sorted by Mod descending + List Scripts // preserve stored DAT order } ScriptAndModData { @@ -221,12 +221,11 @@ ScriptAndModData { ``` The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients. -The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the -incoming `mod`. That's how a Frost I missile and a Frost VII missile can -share a `PlayScript.Launch` entry but show different-intensity effects: the -spell sets mod=0.3 vs mod=0.95, and the table picks the matching -`PhysicsScript` DID. This is the dispatch lookup done by -`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`). +Retail scans the stored `Scripts` entries and returns the first entry for which +the incoming intensity is less than or equal to `Mod`. Equality matches; the +client neither sorts the entries nor falls back to the first. ACViewer's +`ParticleViewer.GetModIdx()` is only an interpretation aid where it differs +from `PhysicsScriptTableData::GetScript` (`0x00521A20`). ### PlayScript enum — the well-known effect namespace @@ -460,14 +459,14 @@ mapping is setup-specific; for humanoids the well-known indices are: ### Offset Frame -`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied -inside the parent-part's local space. For a torch: `PartIndex=0` (torch -mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the -torch regardless of how the torch is held. The frame is stored in dat -coordinates; our port multiplies it into the part-local transform with -standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) * -Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's -world matrix each frame. +`CreateParticleHook.Offset` is encoded as a complete `Frame` (position plus +quaternion), but retail `Particle::Init` (`0x0051C930`) consumes only its +origin for particle emission. For a torch: `PartIndex=0` (torch mesh), +`Offset.Origin=(0, 0, 0.45)` spawns the fire emitter at the top of the torch +regardless of how the torch is held. Retail transforms that origin and the +emitter's own vectors through the current parent-part/root frame; it does not +apply `Offset.Orientation`. The quaternion is still decoded and retained for +schema fidelity, not multiplied into the particle anchor. ### Retail `set_parent` call diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index 737b9dc2..f92c3f07 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -22,10 +22,11 @@ namespace AcDream.Core.Vfx; /// renderer exposes per-part world transforms. /// /// -/// — same as -/// CreateParticleHook but the retail sequencer blocks animation -/// progression until the particle is done. We spawn the emitter -/// identically; the "blocking" semantics belong to the sequencer. +/// — has the same payload +/// as CreateParticleHook, but suppresses creation while the same nonzero +/// logical emitter ID is live. It does not pause animation. The dependency +/// currently omits that inherited payload; the retail compatibility reader +/// owns its decoding. /// /// /// — stop the most-recent emitter @@ -110,11 +111,11 @@ public sealed class ParticleHookSink : IAnimationHookSink break; case CreateBlockingParticleHook: - // Retail's CreateBlockingParticleHook is a sentinel (no - // payload) — the sequencer pauses advancement until the - // current particle emitter finishes. We surface this as - // "no spawn needed, just keep the current particle alive"; - // blocking semantics live in the sequencer, not here. + // Retail gives this the full CreateParticle payload and + // suppresses creation only while the same nonzero logical ID + // is live; it never pauses animation. The dependency loses + // that payload, so TS-11 remains a no-op until the centralized + // raw-hook compatibility reader supplies the retail shape. break; case DestroyParticleHook dph: diff --git a/tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs b/tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs new file mode 100644 index 00000000..9801b19b --- /dev/null +++ b/tests/AcDream.Content.Tests/Vfx/ProjectileVfxDatFixtures.cs @@ -0,0 +1,135 @@ +using System.Buffers.Binary; + +namespace AcDream.Content.Tests.Vfx; + +/// +/// 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. +/// +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 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 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..])); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs b/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs new file mode 100644 index 00000000..dd907285 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs @@ -0,0 +1,283 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Synthetic, non-DAT packet fixtures captured by the Step 0 retail wire +/// audit. Parser conformance tests consume these from Step 1 onward. +/// +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(); + 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 buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer, value); + bytes.AddRange(buffer.ToArray()); + } + + void WriteU32(uint value) + { + Span 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); + } + } + + /// + /// ACE SerializePhysicsData-shaped fixture with synthetic values. These + /// small packets isolate mutually-exclusive and present-zero branches that + /// a single broad CreateObject cannot cover. + /// + private static byte[] BuildBranchFixture(CreateObject.PhysicsDescriptionFlag flags, uint guid) + { + var bytes = new List(); + 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 buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer, value); + bytes.AddRange(buffer.ToArray()); + } + + void WriteU32(uint value) + { + Span 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 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 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 present = ProjectileVfxPacketFixtures.CreateObjectPeTablePresentZero; + ReadOnlySpan 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)); + } +} diff --git a/tools/PesChainAudit/Program.cs b/tools/PesChainAudit/Program.cs index 3c435ff6..d903d32f 100644 --- a/tools/PesChainAudit/Program.cs +++ b/tools/PesChainAudit/Program.cs @@ -58,7 +58,7 @@ foreach (var root in roots) DumpEmitter(eid, pad + " ", rootForReport); break; case CreateBlockingParticleHook _: - Console.WriteLine($"{head} (no payload — looks at next hook)"); + Console.WriteLine($"{head} (package schema omits retail's inherited CreateParticle payload)"); break; case SoundHook sh: Console.WriteLine($"{head} sound=0x{(uint)sh.Id:X8}"); diff --git a/tools/ProjectileVfxAudit/Program.cs b/tools/ProjectileVfxAudit/Program.cs new file mode 100644 index 00000000..75ca8ccb --- /dev/null +++ b/tools/ProjectileVfxAudit/Program.cs @@ -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(); +var representativePes = new HashSet(); + +var expectedSetups = new Dictionary +{ + [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 +{ + [0x33000D77u] = (108, "9F27628BDEA6B05C95CFADD037746AB380542DF8B040265EF881B3366CB8E12E"), +}; +var expectedRecallAnimations = new Dictionary +{ + [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(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 +{ + [0x3400002Bu] = (504, "C97123E43657C6C04812FB9B04C893DB057069695748DCA5F7F71F2575E30F05"), + [0x34000005u] = (88, "86906B0EDDC9AA2242091EAC7A22C88FB2150F0773B6CCA1FA5E4AAEAFF2A386"), + [0x34000080u] = (56, "BF84C0A7C19821D4B9282D0A37F46B9C87E4B59FC9AD29263F7F6DDF5C0F65DA"), + [0x34000081u] = (56, "D65B0B64E1EEA997C7B6AF3DA99A050D32F10738A1393207FB933AC7AE112DFA"), + [0x34000082u] = (56, "BDF28D167425C3835CD9FBE8556D414D3E58A477A7F840D6DC89072676A39A56"), +}; +foreach (uint id in tableIds) +{ + PhysicsScriptTable? table = SafeGet(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(); +foreach (uint pes in representativePes.Order()) + DumpPhysicsScript(pes, representativeVisited, ""); + +Console.WriteLine("\n=== installed Hidden / UnHide table entries ==="); +foreach (uint id in dats.GetAllIdsOfType()) +{ + PhysicsScriptTable? table = SafeGet(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(); +Console.WriteLine("\n=== local-DAT LifestoneRecall 0x10000153 motion chains ==="); +foreach (uint id in dats.GetAllIdsOfType()) +{ + MotionTable? table = SafeGet(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(); +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(); +foreach (uint pes in reachablePes.Order()) + DumpPhysicsScript(pes, visitedPes, ""); + +Console.WriteLine("\n=== installed CreateBlockingParticle occurrences (raw retail-layout scan) ==="); +var blockingPes = FindRawBlockingHooks(dats.GetAllIdsOfType()); +Console.WriteLine($"PhysicsScripts: {string.Join(", ", blockingPes.Select(x => $"0x{x:X8}"))}"); +foreach (uint id in blockingPes) PrintRawMetadata(id); + +var blockingAnimations = FindRawBlockingHooks(dats.GetAllIdsOfType()); +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(uint id) where T : class, IDBObj +{ + try { return dats.Get(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 FindRawBlockingHooks(IEnumerable ids) +{ + var matches = new List(); + 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 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 pesRoots) +{ + DatAnimation? animation = SafeGet(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 visited, string indent) +{ + if (!visited.Add(id)) return; + DatPhysicsScript? script = SafeGet(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() ?? "", +}; diff --git a/tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj b/tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj new file mode 100644 index 00000000..2d25ab50 --- /dev/null +++ b/tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + +