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