# 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<PhysicsScriptData> 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<ParticleEmitter> 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<GfxObj>` | software-render mesh (ignored by retail — always uses HW) |
| `HwGfxObjId` | `QualifiedDataId<GfxObj>` | 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<PlayScript, PhysicsScriptTableData> ScriptTable;
// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc.  (62 values)
// PhysicsScriptTableData = List<ScriptAndModData> Scripts (weighted variants)
// ScriptAndModData = { float Mod; QualifiedDataId<PhysicsScript> ScriptId; }
```

Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Enables "when I die, pick a death-sound script with weight = Mod". Not relevant for sky, but relevant for NPC/monster/spell PES.

### 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<PhysicsScript>
    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<PhysicsScript>(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<ParticleEmitterInfo>(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<PhysicsScript>(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.
