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>
12 KiB
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):
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:
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:
- Allocate a PES instance per SkyObject
- Hold a "currently-running PES" back-pointer anywhere in SkyObject, per-frame table entry, Region, SkyDesc, or DayGroup
- 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:
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:
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:
- Grows/shrinks the output table size to match current DayGroup's
SkyObject.Count(lines 2430-2480) - For each SkyObject, copies
GfxObjId/PesObjectId/Properties/Rotate/ArcAngle/TexVelinto the per-frame entry - 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(thePhysicsScriptreference 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
ParticleEmitterattached 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
DefaultPesObjectIdin the parsed struct (we already do —SkyObject.DefaultPesObjectIdinSkyState.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
WeatherManagerthat lives next toWeatherStateenum +EnvironChangehandling, attaches emitters to the camera entity, and is triggered by region-load + fog-keyframe transitions. That manager may scan each SkyObject'sDefaultPesObjectIdas 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_00508010does not call PES. Evidence: full line-by-line read; grep of entirechunk_00500000.cfor anyFUN_0051bXX/FUN_0051cXX— zero hits. - High:
FUN_00502a10copies PesObjectId through but doesn't act on it. Evidence: line 2492 writes+0x04 = *(iVar4+0x28); nothing else in the function reads+0x04. - High:
FUN_0051bed0is the PhysicsScript launcher and is called only fromFUN_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
DefaultPesObjectIdon 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*andchunk_004*for calls toFUN_0051bed0with a parameter sourced from a SkyDesc/SkyObject field. If it exists, it'll show up as a single call in a function that also touchesDAT_0084247c(region global). - Scan retail dats for populated PesObjectIds.
python tools/decompile_acclient.pyhas no dat-scan helper, but the ACERegion.csloader would parse every region — quick C# one-shot to tally non-zero Region.DayGroups[].SkyObjects[].DefaultPesObjectId values across all region IDs0x13000000..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.