# Sky PhysicsScript (PES) Wiring — Decompile Research **Date:** 2026-04-23 **Scope:** Lifecycle of `SkyObject.DefaultPesObjectId` PhysicsScript emitters inside retail's `FUN_00508010` sky draw loop. **Prior work:** `2026-04-23-sky-decompile-hunt-A.md` (sky renderer call graph), `2026-04-23-sky-material-state.md` (per-mesh state). --- ## TL;DR — retail does NOT spawn/run a PES inside the sky loop **After a line-by-line read of `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, and the entire `FUN_0051bed0` (PhysicsScript::Run) call graph, retail's sky renderer never invokes any PhysicsScript-runner function.** The `DefaultPesObjectId` (offset `+0x28` in `SkyObject`, copied to `+0x04` of each per-frame table entry) is **parsed from the dat stream, copied into the per-frame entry, and then ignored by the draw loop**. This flips the mission premise. Every question Q1–Q4 has the same answer: **retail doesn't do it here.** The PES-from-SkyObject pathway is dead code at the render stage — either disabled in retail, or the id is consumed by code outside `chunk_00500000.c` that isn't called from the sky path we traced. The r12 deepdive note at `deepdives/r12-weather-daynight.md:423-426` corroborates: *"Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` … **that attaches a particle emitter to the camera**."* The emitter lives on the camera, not on the sky entity, and the dat files for retail-shipped regions don't actually populate it on any sky object the audit has examined. Full evidence below. --- ## Q1 — PES-start call site inside `FUN_00508010` **There is none.** Full loop body (`chunk_00500000.c:7567-7599`): ```c do { if (*(int *)(param_1[3] + uVar7 * 4) != 0) { // slot has GfxObjId? uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1); // +0x08 = Rotate override (NOT Pes) uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc); // +0x0c = Arc angle local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0; // identity quat local_14 = 0; local_10 = 0; local_c = 0; // zero translation FUN_00535b30(); // reset current xform if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { // Properties bit 2 set? iVar5 = *(int *)param_1[3]; local_14 = *(undefined4 *)(iVar5 + 0x84); // custom translation X local_10 = *(undefined4 *)(iVar5 + 0x88); // Y local_c = *(undefined4 *)(iVar5 + 0x8c); // Z } FUN_005079e0(&local_48, uVar3, uVar4); // rotate (mesh-roll + arc) FUN_00514b90(&local_48); // enqueue mesh draw if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1)) FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0); // Luminosity if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1)) FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0); // MaxBright if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1)) FUN_005120c0( *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0); // Transparent } uVar7 = uVar7 + 1; iVar6 = iVar6 + 0x2c; } while (uVar7 < uVar2); ``` **Offsets touched inside the loop:** `+0x08, +0x0c, +0x1c, +0x20, +0x24` and the Properties byte. **`+0x04` (the PesObjectId slot) is NEVER read** anywhere in this function or in `FUN_004ff4b0`/`FUN_00502a10`'s render-time code path. A grep confirms no occurrence of `iVar6 + 4 + *param_1` or `iVar6 + 0x04 + *param_1` in `chunk_00500000.c`. The previous audit (`2026-04-23-sky-decompile-hunt-A.md` §5.3) inferred `uVar3` was rotation-axis-1, but labeled its source as "unknown field at +8". That field is **the `Rotate` override from `SkyObjectReplace+0x0c`** — proven by `FUN_00502a10:2532-2534`: ```c fVar1 = *(float *)(*(int *)(local_34 + 0x2c) + local_38 * 4) + 0xc); // Replace.Rotate if (fVar1 != DAT_00796344) { *(float *)(uVar6 * 0x2c + 8 + *piVar5) = fVar1; // stored at per-frame+0x08 } ``` So the `+0x08` slot is a **mesh-roll angle**, not a PhysicsScript pointer. --- ## Q2 — PES lifecycle for visible SkyObjects **There is no lifecycle.** The sky draw path does not: 1. Allocate a PES instance per SkyObject 2. Hold a "currently-running PES" back-pointer anywhere in SkyObject, per-frame table entry, Region, SkyDesc, or DayGroup 3. Call `FUN_0051bed0` (the PhysicsScript launcher) anywhere in the sky-render tree (`FUN_005062e0`, `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, `FUN_00507e20`, `FUN_005079e0`, `FUN_00514b90`) Verified by: ``` $ grep -n "FUN_0051bed0\|FUN_0051be40\|FUN_0051bfb0\|FUN_0051c040" chunk_00500000.c (no results) ``` `FUN_0051bed0` (the PhysicsScript runner) is located in `chunk_00510000.c:11121`: ```c undefined4 FUN_0051bed0(undefined4 param_1) { // param_1 = PhysicsScript dat ID uVar1 = FUN_004220b0(param_1, 0x2b); // type 0x2b = PHYSICS_SCRIPT iVar2 = FUN_00415430(uVar1); // dat-load if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { // queue return 1; } return 0; } ``` Its only caller is `FUN_005117a0` (`chunk_00510000.c:1504`), which is the **PhysicsObject::RunScript** method: ```c undefined4 FUN_005117a0(int param_1, int param_2) { // this=PhysicsObject, param_2=ScriptId if (*(int *)(param_1 + 0x30) == 0) { // lazy-alloc ScriptManager at +0x30 iVar1 = FUN_005df0f5(0x18); if (iVar1 == 0) uVar2 = 0; else uVar2 = FUN_0051be20(param_1); *(undefined4 *)(param_1 + 0x30) = uVar2; } if (*(int *)(param_1 + 0x30) != 0) { uVar3 = FUN_0051bed0(param_2); } return uVar3; } ``` Every caller of `FUN_005117a0` is in PhysicsObject / weapon / combat code (`chunk_00510000.c:2432, 2470, 3719, 3741, 3771, 4190, 4855, 5231, 5261`). **None are in the sky renderer.** --- ## Q3 — Day-change & DayGroup-change handling No such code. The SkyObject table rebuild in `FUN_00502a10` (triggered every frame via `FUN_004ff4b0`) does: 1. Grows/shrinks the output table size to match current DayGroup's `SkyObject.Count` (lines 2430-2480) 2. For each SkyObject, copies `GfxObjId/PesObjectId/Properties/Rotate/ArcAngle/TexVel` into the per-frame entry 3. Overlays the current SkyTimeOfDay's `SkyObjectReplace[]` entries **Nothing in this rebuild path allocates, cleans up, or references a PhysicsScript owner.** `FUN_00502a10` treats `PesObjectId` as an opaque dword — copy from `SkyObject+0x28` to per-frame entry `+0x04` (line 2492) — and that's the last time it's touched. The only "lifecycle" seen is the DayGroup variant roll (`FUN_00501990`), which re-rolls *which* DayGroup is active based on a deterministic hash of the player weenie's state. That affects which `SkyObject[]` gets iterated, but again — nothing in the DayGroup-change path touches PES. --- ## Q4 — The particle-emitter parent Per the r12 deepdive `deepdives/r12-weather-daynight.md:423-426, 447-476`: > Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the sky object) **that attaches a particle emitter to the camera**. This emitter fires rain/snow particles regardless of the server. > Rain in AC is a `ParticleEmitter` **attached to the camera** at an offset of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing streak-style particles downward. So the **owner is the camera PhysicsObject**, not any SkyObject. When (if) retail does emit weather particles, it's via the camera's own `RunScript` invoked from a code path we haven't traced — likely a weather manager hooked to `EnvironChange` events, not to the sky-render loop. Given the `DefaultPesObjectId` isn't read during render, the most likely place it would be consumed is **region-load time** — when `FUN_004ff370` loads the Region and its SkyDesc, a weather manager could walk every SkyObject, find any non-zero PesObjectId, and use it to initialize a camera-attached emitter template. But no such code was found inside `chunk_00500000.c` or the Region loader path; it would live in a separate weather/particle subsystem (probably `chunk_00510000.c` or `chunk_005A0000.c`). --- ## Q5 — Port-ready pseudocode Because retail does not run PES per sky object, the port pseudocode is the null program: ``` frame tick: for each SkyObject in current DayGroup: # exactly what FUN_00508010 does — draw the mesh, apply T/L/MB overrides. # DefaultPesObjectId is copied into the per-frame table at +0x04 but never read. visible_now = (BeginTime == EndTime) OR (BeginTime < t < EndTime) if visible_now AND entry.GfxObjId != 0: draw mesh with Rotate/ArcAngle rotations apply Luminosity/MaxBright/Transparent overrides if > 0 # NO PES START/STOP/UPDATE on DayGroup change: # FUN_00501990 re-rolls active DayGroup index by deterministic hash. # Does NOT touch any script state. nop on Region unload: # FUN_004ff3b0 releases Region via vtable[0x14]; no sky-specific PES cleanup. nop ``` **What to do for acdream:** - **Ship Phase 2 sky as geometry-only.** Do NOT add a SkyObject→ParticleEmitter spawn path based on `DefaultPesObjectId`. It would not match retail. - **Retain `DefaultPesObjectId` in the parsed struct** (we already do — `SkyObject.DefaultPesObjectId` in `SkyState.cs`). It's data retail loads but doesn't use at render; keep it so future weather code can inspect it if we implement the camera-emitter path. - **Weather particles are a SEPARATE feature.** If/when implemented, they belong in a `WeatherManager` that lives next to `WeatherState` enum + `EnvironChange` handling, attaches emitters to the camera entity, and is triggered by region-load + fog-keyframe transitions. That manager *may* scan each SkyObject's `DefaultPesObjectId` as one of its inputs, or it may use a hard-coded per-WeatherState table (rain.pes, snow.pes). Either approach is off the sky-render critical path. --- ## Confidence - **High**: `FUN_00508010` does not call PES. Evidence: full line-by-line read; grep of entire `chunk_00500000.c` for any `FUN_0051bXX` / `FUN_0051cXX` — zero hits. - **High**: `FUN_00502a10` copies PesObjectId through but doesn't act on it. Evidence: line 2492 writes `+0x04 = *(iVar4+0x28)`; nothing else in the function reads `+0x04`. - **High**: `FUN_0051bed0` is the PhysicsScript launcher and is called only from `FUN_005117a0` (PhysicsObject::RunScript), never from sky code. - **Medium**: Weather particles are camera-attached and sourced from a separate subsystem. Evidence: r12 deepdive assertion + absence of any sky-side PES spawn. The weather subsystem itself was not located in this hunt. - **Unknown**: Whether any retail-shipped region dat (Dereth, dungeons) actually populates `DefaultPesObjectId` on any SkyObject. Worth a dat scan: open every Region's SkyDesc and tally non-zero PesObjectIds. If the answer is "zero across all regions", the field is effectively dead data in retail and our "do nothing" port is 100% correct. If some regions populate it, there's a weather subsystem somewhere that reads it — but not from the render path. --- ## Pointers for future work - **Locate the weather manager.** Grep `chunk_005*` and `chunk_004*` for calls to `FUN_0051bed0` with a parameter sourced from a SkyDesc/SkyObject field. If it exists, it'll show up as a single call in a function that also touches `DAT_0084247c` (region global). - **Scan retail dats for populated PesObjectIds.** `python tools/decompile_acclient.py` has no dat-scan helper, but the ACE `Region.cs` loader would parse every region — quick C# one-shot to tally non-zero Region.DayGroups[].SkyObjects[].DefaultPesObjectId values across all region IDs `0x13000000..0x1300FFFF`. - **Confirm weather is independent of sky rendering** by verifying that acdream's rain/snow (if we ever implement them) can render with sky renderer disabled and vice-versa. This is the retail behavior per the r12 writeup.