acdream/docs/research/2026-04-23-sky-pes-wiring.md
Erik 53608e77e3 sky(phase-5a): remove DayGroup-name rain hack, ship retail-only Overcast mapping
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>
2026-04-24 11:04:36 +02:00

12 KiB
Raw Permalink Blame History

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 Q1Q4 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.DefaultPesObjectIdthat 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:

  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:

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:

  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.