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

26 KiB
Raw Blame History

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.

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.

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.

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-37empty 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.

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)

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:

// 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)

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)

// 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

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-45CreateParticleEmitter(obj, emitterInfoID, partIdx, offset, emitterID).
  • references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255UpdateParticles() — 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-93ShouldEmitParticle dispatches on EmitterType (BirthratePerMeter uses Δorigin since last emit; others use Δtime).
  • references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:130-152EmitParticle 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 pathFUN_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:

// 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:

// 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:

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