# PhysicsScript — Retail Runtime Research **Date:** 2026-04-23 **Goal:** Port retail's PhysicsScript (PES) system verbatim so acdream's sky can play per-SkyObject effects (e.g. `DefaultPesObjectId = 0x330007DB` on DayGroup[0] SkyObject[6]). **Outcome:** Runtime fully located in decompile. ACE / ACViewer ports are skeletons — acdream must actually implement this. Dat schema is complete and simple. Integration with sky is NOT automatic — retail's sky render loop does not itself spawn PES; we must add a walker. --- ## Q1. PhysicsScript dat schema (complete) ### `PhysicsScript` (DB_TYPE_PHYSICS_SCRIPT, range `0x33000000..0x3300FFFF`) Source: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:26-55`. ```csharp public partial class PhysicsScript : DBObj { public List ScriptData; // count + N entries } ``` ### `PhysicsScriptData` (per-command entry) Source: `references/DatReaderWriter/DatReaderWriter/Generated/Types/PhysicsScriptData.generated.cs:22-44`. ```csharp public partial class PhysicsScriptData { public double StartTime; // seconds offset from script start public AnimationHook Hook; // polymorphic — peeked as uint type prefix } ``` Unpack: `StartTime (double) → peek AnimationHookType (uint, don't consume) → AnimationHook.Unpack(reader, type)`. ### `AnimationHook` subtypes used by sky/PES `AnimationHookType` (source: `Generated/Enums/AnimationHookType.generated.cs:13-70`): | Value | Name | Relevant for PES? | |---|---|---| | 0x0D | **CreateParticle** | **YES** — spawn emitter at part index / offset | | 0x0E | **DestroyParticle** | **YES** — despawn emitter by EmitterId | | 0x0F | **StopParticle** | **YES** — stop spawn, let alive particles die | | 0x1A | **CreateBlockingParticle** | Rare; emitter-id dedupe variant | | 0x13 | **CallPES** | **YES** — one script calls another | | 0x01 | Sound | audio hook (less critical for sky) | | 0x0A/0x0B | Diffuse/DiffusePart | per-surface color | | 0x08/0x09 | Luminous/LuminousPart | override Surface.Luminosity | | 0x14 | Transparent | override Surface.Transparency | | 0x16 | SetOmega | spin rate | | 0x17/0x18 | TextureVelocity[Part] | UV scroll | | 0x19 | SetLight | light override | ### `CreateParticleHook` — the main one Source: `Generated/Types/CreateParticleHook.generated.cs:22-54`. ```csharp public partial class CreateParticleHook : AnimationHook { public QualifiedDataId EmitterInfoId; // 0x32xxxxxx public uint PartIndex; // which part of the PhysicsObj to attach to public Frame Offset; // origin + orientation (Vec3 + Quat) public uint EmitterId; // runtime handle for later Destroy/Stop hooks } ``` ### `DestroyParticleHook` / `StopParticleHook` — by EmitterId Both carry a single `uint EmitterId` (lines 27-30 of respective generated files). Destroy removes the emitter; Stop flips `Stopped = true` and lets live particles finish their lifespan. ### `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. ### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`) Identical on-disk layout — both `ParticleEmitter.generated.cs` and `ParticleEmitterInfo.generated.cs` unpack the same 31 fields in the same order. Schema summary (source: `Generated/DBObjs/ParticleEmitter.generated.cs:34-208`): | Field | Type | Purpose | |---|---|---| | `Unknown` | uint | unused | | `EmitterType` | enum | `Still`, `BirthratePerSecond`, `BirthratePerMeter`, … | | `ParticleType` | enum | `Still`, `Local`, `Parabolic`, `Swarm`, `Explode`, `Implode` | | `GfxObjId` | `QualifiedDataId` | software-render mesh (ignored by retail — always uses HW) | | `HwGfxObjId` | `QualifiedDataId` | hardware-render mesh (1 per particle) | | `Birthrate` | double | seconds between spawns | | `MaxParticles` | int | live cap | | `InitialParticles` | int | spawn count at t=0 | | `TotalParticles` | int | 0 = unlimited | | `TotalSeconds` | double | 0 = infinite | | `Lifespan`, `LifespanRand` | double | per-particle life ± rand | | `OffsetDir`, `MinOffset`, `MaxOffset` | Vec3, 2×float | spawn position randomizer | | `A`,`MinA`,`MaxA` | Vec3, 2×float | velocity axis A | | `B`,`MinB`,`MaxB` | Vec3, 2×float | velocity axis B | | `C`,`MinC`,`MaxC` | Vec3, 2×float | velocity axis C (for e.g. Parabolic gravity) | | `StartScale`,`FinalScale`,`ScaleRand` | float | scale lerp | | `StartTrans`,`FinalTrans`,`TransRand` | float | transparency lerp (0=opaque … 1=transparent in retail) | | `IsParentLocal` | bool | follow parent transform each frame | `ParticleType` enum options drive the per-particle integrator shape (linear, ballistic, etc.). `EmitterType` drives `ShouldEmitParticle()` logic (ACE `ParticleEmitterInfo.cs:ShouldEmitParticle`). ### `PhysicsScriptTable` (DB_TYPE_PHYSICS_SCRIPT_TABLE, `0x34000000..0x3400FFFF`) 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) // 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. ### Retail factory registration (chunk_00410000.c:13439-13451) ```c local_8 = 3; // some flag local_4 = 0xf; // flag local_e = 0; FUN_0041f900(&DAT_00796578, local_3c + 1); // set type name "PhysicsScript" local_3c[1] = 0x33000000; // range lo local_3c[2] = 0x3300ffff; // range hi FUN_00401340(&DAT_00796734); // vtable pointer FUN_0040c440(local_3c); // register-factory call ``` Type-index (from chunk_00410000.c:10675): **`0x2b`** for PhysicsScript, `0x2a` for ParticleEmitterInfo (via symmetric branch), `0x2c` for PhysicsScriptTable. The loader dispatch uses these. --- ## Q2. Retail runtime — `FUN_0051be40`/`FUN_0051bed0`/`FUN_0051bf20`/`FUN_0051bfb0` All citations: `docs/research/decompiled/chunk_00510000.c`. ### The ScriptManager class — lives at `PhysicsObj + 0x30` From line 1517-1528: ```c // FUN_005117?? — PhysicsObj::play_script_internal(self, scriptID) if (*(int *)(param_1 + 0x30) == 0) { // no manager yet? iVar1 = FUN_005df0f5(0x18); // allocate 24-byte manager if (iVar1 != 0) { uVar2 = FUN_0051be20(param_1); // ScriptManager::ctor(self) } *(undefined4 *)(param_1 + 0x30) = uVar2; } if (*(int *)(param_1 + 0x30) != 0) { uVar3 = FUN_0051bed0(param_2); // manager.AddScript(scriptID) } ``` **ScriptManager layout** (inferred from FUN_0051be20, 24 bytes at `+0x30`): ``` +0x00 ownerPhysicsObj* +0x04 head* (ScriptNode linked-list head) — called from FUN_0051bfb0:11187 +0x08 tail* +0x0c lastIndex (init 0xFFFFFFFF) +0x10 nextTickTime (double, bytes 0x10..0x17) +0x18 ... ``` ### `FUN_0051bed0` — public script loader (line 11121) ```c undefined4 FUN_0051bed0(undefined4 scriptID) { uVar1 = FUN_004220b0(scriptID, 0x2b); // make QualifiedDataId iVar2 = FUN_00415430(uVar1); // DB lookup — returns PhysicsScript* if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { return 1; } return 0; } ``` ### `FUN_0051be40` — ScriptManager::Start (line 11078) Allocates a 16-byte ScriptNode: `{ double startTime; PhysicsScript* script; ScriptNode* next; }`. Sets `startTime = globalClock (DAT_008379a8)` or `prev.startTime + prev.script.Lifespan_at_0x48`. Links into tail. ### `FUN_0051bf20` — ScriptManager::AdvanceOneHook (line 11139) ```c // Compact paraphrase: int idx = ++manager.hookIndex; // pdVar2+0xc PhysicsScript* script = manager.head->script; // (*(pdVar2+1)) int hookCount = script->count_at_0x44; if (hookCount <= idx) return 0; // done // Peek next hook's StartTime to schedule next tick if (idx+1 < hookCount) manager.nextTickTime = head.startTime + script.hooks[idx+1].StartTime; else if (head.next != NULL) manager.nextTickTime = head.next.startTime + head.next.script.hooks[0].StartTime; else manager.nextTickTime = -1.0; // sentinel 0xBFF00000 = -1.0 as double-hi return script.hooks[idx].Hook; // pointer to AnimationHook for execution ``` Offsets here decoded: `script + 0x38` = hooks array, `script + 0x44` = hooks count, each hook entry at `+hookIdx*4` is a `PhysicsScriptData*` with `+0x00` StartTime (double) and `+0x08` Hook* pointer. ### `FUN_0051bfb0` — ScriptManager::Tick (line 11178) — called every frame per physics object ```c int head = manager.head; while (head != 0 && manager.nextTickTime <= globalClock_DAT_008379a8) { Hook* h = FUN_0051bf20(manager); // returns next hook or NULL=done if (h == NULL) { // current script done → pop to next script prev = manager.head; manager.head = prev.next; manager.lastIndex = -1; if (manager.head == NULL) { manager.nextTickTime = -1.0; manager.tail = NULL; } else { manager.nextTickTime = manager.head.startTime + manager.head.script.hooks[0].StartTime; } delete prev; } else { // Execute: virtual dispatch on hook type (**(code **)(*h + 4))(ownerPhysicsObj); } head = manager.head; } ``` The hook is a vtable-dispatched virtual call — retail's AnimationHook derived classes implement `execute(PhysicsObj* self)` at vtable slot 1 (`+4`). For `CreateParticleHook` this calls `self->ParticleManager->CreateParticleEmitter(emitterInfoId, partIndex, &offset, emitterId)`. ### `FUN_0051bda0` — AnimationTable::appendScriptEntry (line 11037) Used at line 289/322 in `FUN_00510340` (which is AnimationTable-level, not ScriptManager). Part of the broader animation hook infrastructure; not on the PES hot path. --- ## Q3. Particle-emitter runtime **Retail code:** not in this decompile chunk extract (would be elsewhere in chunk_00510000.c); the class instantiation is done by each `CreateParticleHook.execute()`. Best available C# port is ACE's `ParticleEmitter.cs`. Key ACE sources (read these for the actual per-particle math — ACE is faithful here even though its outer `PhysicsScript` class is empty): - `references/ACE/Source/ACE.Server/Physics/Particles/ParticleManager.cs:26-45` — `CreateParticleEmitter(obj, emitterInfoID, partIdx, offset, emitterID)`. - `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255` — `UpdateParticles()` — the per-frame tick. Separates degrade-distance-culled and active paths. When non-culled, walks each particle slot: `frame = IsParentLocal ? parent.Frame : particle.StartFrame; particle.Update(ParticleType, firstParticle, part, frame); KillParticle(i);` - `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:83-93` — `ShouldEmitParticle` dispatches on `EmitterType` (`BirthratePerMeter` uses Δorigin since last emit; others use Δtime). - `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:130-152` — `EmitParticle` picks a free slot and calls `Particle.Init(info, parent, partIdx, parentOffset, part, randomOffset, firstParticle, randomA, randomB, randomC)`. **Important caveat:** ACE's `ParticleEmitter` references `SortingSphere`, `HWGfxObjID`, `ShouldEmitParticle(numParticles, totalEmitted, offset, lastEmitTime)` on `ParticleEmitterInfo` — these are runtime-interpretive helpers, not raw dat fields. The raw dat has the 31-field struct above; ACE augments it with derived properties. ### Relevance for sky (Q4) — NEGATIVE ACE's `ParticleEmitter` is tightly parent-bound to a `PhysicsObj` (`parent.PartArray.Parts[partIndex].Pos.Frame`). Retail PES binds to a PhysicsObj via `CreateParticleHook.PartIndex`. A SkyObject in retail is a PhysicsObj (via `FUN_00514470` — line 7500 in chunk_00500000.c, which allocates 0x178 bytes = sizeof(PhysicsObj) and sets up the mesh). **So a sky-object IS a PhysicsObj**, and its script would attach to *that*. --- ## Q4. Sky → PES connection — THE ACTUAL STATE **Claim to verify: does the retail sky loop actually spawn PES from `DefaultPesObjectId`?** Cross-references into `FUN_00508010` (sky render loop, chunk_00500000.c:7535-7603) and `FUN_00507e20` (sky table refresh, chunk_00500000.c:7414-7527): ### What the sky loop does consume from the per-frame entry Per-entry layout (from `FUN_00502a10` writes, chunk_00500000.c:2491-2510) — 0x2c bytes: ``` +0x00 GfxObjId ← FUN_00508010:7569 (read into uVar3) +0x04 PesObjectId ← NEVER READ by FUN_00508010 or FUN_00507e20 +0x08 runtime "axis1" ← FUN_00508010:7570 (read into uVar4 → ApplyRotations) +0x0c CurrentArcAngle ← (degree interp) +0x10..0x18 TexVelocityX/Y/runtime +0x1c Transparent ← FUN_00508010:7593 +0x20 Luminosity ← FUN_00508010:7587 +0x24 MaxBright ← FUN_00508010:7590 (also FUN_00507940:7218) +0x28 Properties ← FUN_00507e20:7498 (goes to param_1[6] flags array) ``` **The sky render loop reads offsets 0x00, 0x08, 0x0c, 0x1c, 0x20, 0x24 and 0x28. It never touches 0x04 (PesObjectId).** ### What actually runs the PES (the real path) `FUN_00507e20:7500` calls `FUN_00507940(GfxObjId_at_+0x00, &entry.TransformOffset_at_+0x10, flag&1_bouncy, flag&4_customPos)`. That → `FUN_00514470` at chunk_00510000.c:4153, which **allocates a PhysicsObj (0x178 bytes) for the sky object** and runs `FUN_005131b0(GfxObjId, 1)` (Setup loader). The sky object's PhysicsObj is stored in `param_1[3]` (the third field-array of the sky table) — one live PhysicsObj per visible sky entry. **But that's for the GfxObj, not the PES.** The PES would run via the normal PhysicsObj-level `play_script` path — if something called `sky.physObj.play_script(entry.PesObjectId)`. I searched for such a call: **no caller of `FUN_005117??` (play_script) in chunk_00500000.c references the sky entry's +0x04 offset.** I also searched for the `FUN_0051bed0` public entry — one call only (chunk_00510000.c:1528), inside the PhysicsObj public `play_script`. No sky-specific caller. ### Best-fit interpretation **The retail sky does NOT automatically run `DefaultPesObjectId`.** Looking at where it WOULD happen, there are three plausible places retail might wire it up that I haven't yet located: 1. **`FUN_00507940` inner** — this is the sky-object instantiation. It could internally call `play_script(entry.PesObjectId)` on the newly-created PhysicsObj. **Its decompile extract (lines 7201-7221) reads only `param_1+0x24`/`+0x28` and does NOT dispatch a script**, so this path is ruled out on the extract we have. 2. **Region tick path** — `FUN_005062e0` (per-frame sky tick) could walk the table and call play_script per entry. The code at chunk_00500000.c:6213-6683 passed through earlier showed only `FUN_00508010` (render) and light/fog lerps — no PES walker. 3. **`FUN_00507e20` spawn-side** — the "new entry" branch at chunk_00500000.c:7497-7502 is the `LAB_00507fb6` label. After building the PhysicsObj (`FUN_00507940`), it stores only the PhysicsObj into `param_1[3]` and the flags into `param_1[6]`. **No PES play here either.** **Honest conclusion:** In the portions of the decompile I examined, retail's sky pipeline creates a PhysicsObj per sky-object for rendering but **does NOT spawn its `DefaultPesObjectId` as a PhysicsScript**. Either (a) the feature is dead code — the `DefaultPesObjectId` field on SkyObject is schema-level but unused by retail, or (b) the wiring lives in a retail code region I haven't yet mapped (possible candidate: the `FUN_00507e20` caller chain or a post-Region-load initializer). For acdream, this means: - **If we want visible sky PES, we add the walker ourselves.** It's an acdream extension to a schema-level dat feature retail may not have actually used. Low-risk (no retail regression to match) but also — we have no ground truth for "does this look right?". - **Evidence gathering:** run retail (or ACE + a retail client that matches the live server) and observe: does the afternoon sky (DayGroup[0] slot 6) exhibit visible particle effects? If no, retail doesn't run this. If yes, we missed a call site. --- ## Q5. Port-ready pseudocode (C#-flavored) ### 5.1 `PhysicsScript` class (dat-backed) acdream already has `ParticleSystem.PlayScript(uint scriptId, uint targetObjectId, float modifier)` (`src/AcDream.Core/Vfx/ParticleSystem.cs:88`). We extend it with a real implementation: ```csharp // New file: src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs public sealed class PhysicsScriptNode { public double StartTimeSeconds; // absolute game clock public PhysicsScript Script; public int HookIndex = -1; public double NextHookAbsTime; // StartTimeSeconds + Script.ScriptData[HookIndex+1].StartTime public PhysicsScriptNode Next; } public sealed class ScriptManager // attaches to one "target" (Sky object, PhysicsObj, etc.) { public uint OwnerObjectId; // for emitter parenting public PhysicsScriptNode Head; public PhysicsScriptNode Tail; // Returns true if script started (dat found + non-empty). public bool Start(double nowSeconds, PhysicsScript script, float modifier) { if (script == null || script.ScriptData.Count == 0) return false; var node = new PhysicsScriptNode { StartTimeSeconds = (Tail == null) ? nowSeconds : Tail.StartTimeSeconds + /*lifespan*/ 0.0, Script = script, }; node.NextHookAbsTime = node.StartTimeSeconds + script.ScriptData[0].StartTime; if (Tail != null) Tail.Next = node; else Head = node; Tail = node; // `modifier` is not consumed by PhysicsScript itself — it's used by // PhysicsScriptTable.GetScript to *pick* which script. Ignore here. return true; } public void Tick(double nowSeconds, IParticleSystem particles) { while (Head != null && Head.NextHookAbsTime <= nowSeconds) { var node = Head; int next = node.HookIndex + 1; if (next >= node.Script.ScriptData.Count) { // Pop this script Head = node.Next; if (Head == null) Tail = null; continue; } node.HookIndex = next; var data = node.Script.ScriptData[next]; ExecuteHook(data.Hook, particles); // Schedule next within this script, or fall through to next script's first hook int peek = next + 1; if (peek < node.Script.ScriptData.Count) node.NextHookAbsTime = node.StartTimeSeconds + node.Script.ScriptData[peek].StartTime; else if (node.Next != null) node.NextHookAbsTime = node.Next.StartTimeSeconds + node.Next.Script.ScriptData[0].StartTime; else node.NextHookAbsTime = double.MaxValue; // this node done, will be popped above } } private void ExecuteHook(AnimationHook hook, IParticleSystem particles) { switch (hook) { case CreateParticleHook c: particles.SpawnEmitterById( emitterInfoId: c.EmitterInfoId.Id, targetObjectId: OwnerObjectId, partIndex: (int)c.PartIndex, localOffset: c.Offset, // Frame → (Vec3 origin, Quat heading) emitterHandle: c.EmitterId); // used as stable key so Destroy/Stop find it break; case DestroyParticleHook d: particles.DestroyEmitterByScriptHandle(OwnerObjectId, d.EmitterId); break; case StopParticleHook s: particles.StopEmitterByScriptHandle(OwnerObjectId, s.EmitterId, fadeOut: true); break; case CallPESHook cp: // Recursive — spawn another script node bound to same owner var subScript = DatCollection.Read(cp.PlayScriptId.Id); if (subScript != null) Start(/*nowSeconds=*/0, subScript, 1f); // real impl reuses last StartTime break; // Sound / Luminous / Diffuse / Scale / Transparent / SetOmega etc. // are per-PhysicsObj mutations; relevant only once we own PhysicsObj state. default: /* no-op for now — log unknown */ break; } } } ``` ### 5.2 `ParticleSystem` extensions Existing: `src/AcDream.Core/Vfx/ParticleSystem.cs` already has `SpawnEmitter` + `PlayScript(uint,uint,float)` stub. We need: ```csharp // Inside ParticleSystem — uses per-(owner, scriptEmitterId) dictionary so // Destroy/Stop hooks can find what CreateParticle spawned. private readonly Dictionary<(uint owner, uint scriptHandle), int> _byScriptHandle = new(); public int SpawnEmitterById(uint emitterInfoId, uint targetObjectId, int partIndex, Frame localOffset, uint emitterHandle) { var info = DatCollection.Read(emitterInfoId); if (info == null) return 0; var desc = EmitterDescLoader.FromInfo(info, partIndex, localOffset); int handle = SpawnEmitter(desc, targetObjectId); if (emitterHandle != 0) _byScriptHandle[(targetObjectId, emitterHandle)] = handle; return handle; } public void DestroyEmitterByScriptHandle(uint owner, uint scriptHandle) { if (_byScriptHandle.Remove((owner, scriptHandle), out var h)) StopEmitter(h, fadeOut: false); } public void StopEmitterByScriptHandle(uint owner, uint scriptHandle, bool fadeOut) { if (_byScriptHandle.TryGetValue((owner, scriptHandle), out var h)) StopEmitter(h, fadeOut); } ``` ### 5.3 Sky integration (acdream extension — since retail doesn't walk PES) In `SkyState.UpdateSkyObjectsTable(dayFraction)` (or wherever the per-frame SkyObject table is built), add after the visibility cull: ```csharp // Per-visible-SkyObject PES instance cache, keyed by (dayGroupIdx, skyObjectIdx). // Allocates a pseudo-ObjectId so ParticleSystem can parent to the sky-object slot. private readonly Dictionary<(int dg, int so), (uint pseudoObjId, ScriptManager mgr)> _skyPes = new(); private void TickSkyObjectPes(double nowSeconds, IParticleSystem particles) { foreach (var entry in _visibleSkyEntries) { if (entry.PesObjectId == 0) continue; var key = (entry.DayGroupIndex, entry.SkyObjectIndex); if (!_skyPes.TryGetValue(key, out var slot)) { var script = DatCollection.Read(entry.PesObjectId); if (script == null) continue; slot = (pseudoObjId: AllocatePseudoSkyObjId(key), mgr: new ScriptManager()); slot.mgr.OwnerObjectId = slot.pseudoObjId; slot.mgr.Start(nowSeconds, script, modifier: 1f); _skyPes[key] = slot; } slot.mgr.Tick(nowSeconds, particles); // TODO: when sky object leaves visibility window, stop + clean up: // if (!entry.Visible) { particles.ClearOwner(slot.pseudoObjId); _skyPes.Remove(key); } } } ``` The pseudo-ObjectId lets `CreateParticleHook.Offset` attach in "world space at the sky mesh's current transform" — acdream's `ParticleSystem` computes positions from the owner's world frame, so the sky renderer must expose each visible SkyObject's world transform to the particle system via the same pseudoObjId. ### 5.4 Threading / clock Use the same game clock `SkyState` uses (bound to `TimeManager` or whatever feeds `DirBright` etc.). Retail's `_DAT_008379a8` is wall-clock-seconds double. One tick per frame, on the main thread, after Sky state update and before particle GPU upload. --- ## Quick integration checklist 1. Add `PhysicsScript` and `ParticleEmitterInfo` readers to `DatCollection` (they're generated by DatReaderWriter already — just wire type IDs `0x2b` and `0x2a`). 2. New `src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs` with `ScriptManager` + `PhysicsScriptNode` per §5.1. 3. Extend `ParticleSystem` with script-handle registry per §5.2. 4. Add `TickSkyObjectPes` to Sky pipeline per §5.3. 5. Conformance test: load `0x330007DB` and verify parsed `ScriptData` hooks match a dump (e.g. ACViewer can visualize PhysicsScripts — confirm hook order and `StartTime` values). 6. **Before deploying:** confirm retail actually plays these scripts (record gameplay, look for cloud particles). If retail doesn't, don't ship — it's a dead feature. --- ## Citations | Claim | Source | |---|---| | Dat schema PhysicsScript | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:34-55` | | PhysicsScriptData | `Generated/Types/PhysicsScriptData.generated.cs:23-43` | | CreateParticleHook | `Generated/Types/CreateParticleHook.generated.cs:22-54` | | ParticleEmitter schema | `Generated/DBObjs/ParticleEmitter.generated.cs:34-208` | | AnimationHookType enum | `Generated/Enums/AnimationHookType.generated.cs:13-70` | | Factory reg for 0x33xxxxxx | `docs/research/decompiled/chunk_00410000.c:13439-13451` | | Type-index 0x2b | `chunk_00410000.c:10670-10677` (range-dispatch fn) | | Script loader `FUN_0051bed0` | `chunk_00510000.c:11119-11133` | | ScriptManager start `FUN_0051be40` | `chunk_00510000.c:11076-11114` | | Advance `FUN_0051bf20` | `chunk_00510000.c:11137-11170` | | Tick `FUN_0051bfb0` | `chunk_00510000.c:11174-11216` | | Per-object tick hook | `chunk_00510000.c:3479-3481` | | Play-script entry inside PhysicsObj | `chunk_00510000.c:1517-1528` | | Sky loop reads from entry | `chunk_00500000.c:7569-7594` | | PesObjectId written but unread | `chunk_00500000.c:2492` (write) — no matching read in 7414-7527 or 7535-7603 | | Sky mesh → PhysicsObj allocation | `chunk_00510000.c:4159` (`FUN_005df0f5(0x178)`) | | ACE ParticleEmitter update | `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255` | | ACE PhysicsScriptTable (skeleton) | `references/ACE/Source/ACE.Server/Physics/Scripts/PhysicsScriptTable.cs:1-20` | | acdream existing Vfx | `src/AcDream.Core/Vfx/ParticleSystem.cs:24-108` | **Word count:** ~2,250.