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>
502 lines
26 KiB
Markdown
502 lines
26 KiB
Markdown
# 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.
|