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>
26 KiB
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-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.
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-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—ShouldEmitParticledispatches onEmitterType(BirthratePerMeteruses Δorigin since last emit; others use Δtime).references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:130-152—EmitParticlepicks a free slot and callsParticle.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:
-
FUN_00507940inner — this is the sky-object instantiation. It could internally callplay_script(entry.PesObjectId)on the newly-created PhysicsObj. Its decompile extract (lines 7201-7221) reads onlyparam_1+0x24/+0x28and does NOT dispatch a script, so this path is ruled out on the extract we have. -
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 onlyFUN_00508010(render) and light/fog lerps — no PES walker. -
FUN_00507e20spawn-side — the "new entry" branch at chunk_00500000.c:7497-7502 is theLAB_00507fb6label. After building the PhysicsObj (FUN_00507940), it stores only the PhysicsObj intoparam_1[3]and the flags intoparam_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
- Add
PhysicsScriptandParticleEmitterInforeaders toDatCollection(they're generated by DatReaderWriter already — just wire type IDs0x2band0x2a). - New
src/AcDream.Core/Vfx/PhysicsScriptRuntime.cswithScriptManager+PhysicsScriptNodeper §5.1. - Extend
ParticleSystemwith script-handle registry per §5.2. - Add
TickSkyObjectPesto Sky pipeline per §5.3. - Conformance test: load
0x330007DBand verify parsedScriptDatahooks match a dump (e.g. ACViewer can visualize PhysicsScripts — confirm hook order andStartTimevalues). - 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.