acdream/docs/research/2026-04-23-physicsscript.md
Erik 53608e77e3 sky(phase-5a): remove DayGroup-name rain hack, ship retail-only Overcast mapping
User-observed regression 2026-04-23: acdream spawned rain particles
when retail showed no rain at the same server tick. Root cause: my
Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain →
rain particle emitter. That's not what retail does.

Parallel decompile research confirms:
- Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives
  at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it
  from NOWHERE.
- Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render
  loop) never reads SkyObject.DefaultPesObjectId — the field is dead
  at render time. Rain/snow particles in retail come from a separate
  camera-attached weather subsystem that has NOT yet been located.

So the correct behavior is: DayGroup name should only drive
fog/ambient tone (via keyframes, already in the Snapshot path),
never spawn particle emitters. Any retail-faithful particle rain
belongs to a future phase once we find the camera-attached weather
subsystem driver.

Change: MapDayGroupNameToKind now maps all weathery substrings
(storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only
visuals, no particle spawn. Clear names stay Clear. The Rain, Snow,
Storm enum values remain and are still accessible via ForceWeather()
for debug overrides.

Tests updated (WeatherSystemTests): the name→kind theory now expects
Overcast for Rainy/Snowy/Stormy variants.

Also commits the four research docs from this session's parallel
hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding),
lightning timer (negative finding — agent #3), fog on sky
(positive: retail applies fog to sky geometry).

NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE
RANDOM TIMER hypothesis for lightning. User confirms retail does have
visible lightning + thunder. A follow-up agent (#5, in flight as of
this commit) is hunting the real mechanism — PlayScript opcode,
SetLight PhysicsScript hooks, AdminEnvirons side effects, or the
weather-volume draw. This commit does NOT attempt to port lightning.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:04:36 +02:00

502 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.