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>
184 lines
12 KiB
Markdown
184 lines
12 KiB
Markdown
# 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.
|