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>
This commit is contained in:
Erik 2026-04-24 11:04:36 +02:00
parent d5e37694ed
commit 53608e77e3
6 changed files with 1508 additions and 20 deletions

View file

@ -0,0 +1,438 @@
# Lightning Flashes & Weather Crossfade — Decompile Research
**Date:** 2026-04-23
**Scope:** Answer Q1Q5 of the lightning-crossfade hunt brief.
**Source tree:** `docs/research/decompiled/chunk_*.c` (688K lines, decompiled retail acclient.exe).
---
## TL;DR
1. **Retail has NO lightning-flash system.** Not a timer, not an RNG modulator, not a
visual spike. Storm preset 6 sets two fog-color targets (grey 0x969696) and
toggles the crossfade; that's it. "Flashes" in modern ports are an addition.
2. **Weather crossfade is driven entirely by `FUN_0055eb40`** (chunk_00550000.c:11835)
— a 7-way switch on `EnvironChangeType` (param_2). It sets fog-crossfade target
globals (`DAT_008427ac/b0/b4`, `DAT_00842784/88`), sets `DAT_008427a9 = 1`
(active), and resets `_DAT_008427b8 = 0` (progress u).
3. **Crossfade step `_DAT_007c7208`** is a single rdata constant. It's
added each time the `LightTickSize` gate fires (i.e. per sky-keyframe update,
default ~2 seconds). Progress saturates at 1.0 (`_DAT_007938b0`).
4. **AdminEnvirons (0xEA60 = 60000) arrives via `FUN_006ae870`**
(chunk_006A0000.c:13141) and unconditionally calls `FUN_0055eb40` with the
EnvironChangeType int. No auth check, no queueing.
5. **Thunder audio (0x76..0x7B)** is driven by AdminEnvirons subtypes **0x65..0x6A**
(chunk_00550000.c:11906-11994) — each calls `FUN_00551560(soundId, chanId)` ONCE.
No timer. Not auto-linked to storm preset.
---
## Q1: Lightning flash trigger — NOT PRESENT in retail
### What I searched
- `rand()` in chunk_00500000.c (sky): **3 hits, all inside `FUN_00501600` RNG-looking
macros → actually a byte-shuffle for ARGB color lerping** (`FUN_005df4c4`), not RNG.
- `rand()` in chunk_00550000.c (weather): **3 hits (lines 646, 1074, 1102) — all
sound-probability filters in `FUN_00550cf0/FUN_00551430/FUN_005514c0`**, used by
ambient-sound emission, not lightning.
- `rand()` in chunk_005B0000.c:3176-3189 — 256-entry palette shuffle init, unrelated.
- `rand()` in chunk_005C0000.c:5560-5668 — 4 particle-emitter time-jitter seeds,
unrelated.
- `fsinf`/`fcosf` in the sky-keyframe path — only used for sun-direction polar-to-
cartesian conversion (`FUN_00501600:1193-1205`). No other time-based trig.
- String literals `"lightning"|"Lightning"|"thunder"|"Thunder"|"LIGHTNING"|"THUNDER"`:
**one hit, unrelated** (chunk_004B0000.c:2283 = a character-skill UI string
`"ID_CharacterInfo_Augmentation_Resist_Lightning"`).
- Storm preset 6 in `FUN_0055eb40` — sets `*(iVar2 + 0x41) = 1` on the singleton
`DAT_00871354` (via `FUN_00564d30`). I grepped for READS of `+0x41` across the
entire decompile: **there are NONE** outside the singleton's own ctor/reset
paths (chunk_00560000.c:2902, 3105 — both writes of 0). **The storm flag is
write-only; no lightning tick consumes it.**
### Storm preset 6 body — chunk_00550000.c:11885-11896
```c
if (param_2 == 6) {
DAT_008427a9 = 1; // crossfade active
_DAT_008427b8 = 0; // progress u
DAT_008427ac = 0x3f4ccccd; // = 0.8f (target fogStart)
DAT_008427b0 = 0; // target secondary-1 (fogNear)
DAT_008427b4 = 0x42200000; // = 40.0f (target fogFar)
DAT_00842788 = 0x64969696; // target fog color ARGB: A=0x64 R=G=B=0x96 grey
DAT_00842784 = 0x64000000; // target secondary color: A=0x64 RGB=black
iVar2 = FUN_00564d30(); // get weather-mgr singleton
*(undefined1 *)(iVar2 + 0x41) = 1; // storm flag (NEVER READ elsewhere)
return 0;
}
```
### Conclusion
The retail acclient **does not implement lightning flashes**. Storm preset 6 is
visually indistinguishable from other fog-change presets except by color and
the unread `+0x41` storm flag. The "client-side random flash" described in the
r12 deepdive is either:
(a) a later/expansion feature not present in the decompiled build, or
(b) a modern-port embellishment.
If acdream wants lightning, it's an **addition**, not a port. A faithful retail
render is pure dense grey fog during thunderstorm.
---
## Q2: Weather / DayGroup crossfade mechanics
### State variables (all in the 0x842780 cluster)
| Global | Type | Init | Role |
|---|---|---|---|
| `DAT_008427a9` | byte | 0 | **Crossfade active flag** — true = blend keyframe output toward stored weather values |
| `_DAT_008427b8` | float | 0.0 | **Crossfade progress `u`** — 0 at start, saturates at 1.0 |
| `DAT_008427ac` | float | — | **Target fogStart** (weather override) |
| `DAT_008427b0` | float | — | **Target fogNear** (secondary/starfield override) |
| `DAT_008427b4` | float | — | **Target fogFar** (secondary/starfield override) |
| `DAT_00842788` | u32 ARGB | — | **Target primary fog color** (pair with `DAT_008427ac`) |
| `DAT_00842784` | u32 ARGB | — | **Target secondary color** (pair with `DAT_008427b0/b4`) |
| `_DAT_007c7208` | float | **.rdata constant** (value unknown in decompile; see below) | **Per-tick progress step** |
| `_DAT_007938b0` | float | 1.0 (confirmed by division usage across chunk_00440000/00450000) | Upper saturation for `u` |
### Per-frame crossfade block — chunk_00500000.c:6256-6281 (primary channel)
```c
if (DAT_008427a9 != '\0') {
if (_DAT_007938b0 <= _DAT_008427b8) { // u >= 1.0: snap
local_24 = DAT_008427ac; // fogStart = target
local_20 = DAT_00842788; // fogColor ARGB = target
}
else {
// Per-byte lerp on fog color (R, G, B, A individually):
// new = current - (current - target) * u [applied to each byte]
// -- FUN_005df4c4 is the byte clamp/pack helper --
... // 4 byte channels
local_24 = local_24 - (local_24 - DAT_008427ac) * _DAT_008427b8; // fogStart lerp
_DAT_008427b8 = _DAT_008427b8 + _DAT_007c7208; // advance u
}
}
FUN_00505f30(local_24, local_20, local_c, local_18);
```
### Per-frame crossfade block — chunk_00500000.c:6297-6324 (secondary / starfield)
Same structure, but writes `local_1c` (fogNear) ← `DAT_008427b0`, `local_24`
(fogFar) ← `DAT_008427b4`, `local_20` (color) ← `DAT_00842784`. Progress `u` is
the SAME global `_DAT_008427b8` — so both channels advance in lockstep.
### Important: the crossfade step gate
`_DAT_008427b8 += _DAT_007c7208` runs ONLY when the outer "LightTickSize" gate fires
(chunk_00500000.c:6249 `if (_DAT_008427a0 < _DAT_008379a8)`). This gate reschedules
using `*(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10)` = SkyDesc.LightTickSize.
Based on the ACE schema (SkyDesc.LightTickSize in the Region dat), this is
typically 2.0 seconds.
**Duration of a crossfade**: if `_DAT_007c7208` is, say, 1/30 (0.033), then
crossfade completes in 30 light-ticks × 2s = 60 seconds. If it's 1/8 = 0.125,
it's 16 seconds. If it's 1.0, it's 2 seconds (instant within one keyframe step).
The literal value is in .rdata at offset 0x007c7208 and isn't visible in the
decompile — acdream should either (a) start with a tuning-chosen constant
(e.g. 0.1 for 20 s fade) and expose it as a config, or (b) disassemble the
retail binary's .rdata to get the ground-truth value.
### Note on retail client behavior
Because the crossfade step advances at the LightTickSize cadence (not per-frame),
retail's weather change visibly "steps" in ~2-second increments rather than
appearing silky smooth at 60 fps. This matches the known retail look —
"the sky is updating in chunks" rather than continuously.
---
## Q3: AdminEnvirons (0xEA60 = 60000) handler
### Dispatcher — chunk_006A0000.c:13141-13153
```c
undefined4 FUN_006ae870(int param_1, int *param_2)
{
undefined4 uVar1;
if (((param_1 != 0) && (*(int *)(param_1 + 0x40) != 0)) && (*param_2 == 60000)) {
uVar1 = FUN_0055eb40(param_2[1]); // param_2[1] = EnvironChangeType (int)
return uVar1;
}
return 0;
}
```
Wire format: `[u32 opcode=0xEA60][u32 environChangeType]` — just a single int payload.
### `FUN_0055eb40` — EnvironChangeType dispatcher (chunk_00550000.c:11839)
| EnvChangeType | Action | Crossfade? |
|---|---|---|
| 0 (Clear) | Zero all targets; set 008427a9 = 0 (crossfade OFF) | N (off) |
| 1 (RedFog / preset 1) | fogStart 0.4, fogFar 50, color 0x64_R_96_00 | Y |
| 2 (preset 2) | fogStart 0.3, fogFar 50, color 0x64_32_00_96 | Y |
| 3 (preset 3) | fogStart 0.4, fogFar 30, color 0x64_64_64_64 (grey) | Y |
| 4 (preset 4) | fogStart 0.3, fogFar 50, color 0x64_1E_64_00 | Y |
| 5 (preset 5) | fogStart 0.8, fogFar 40, color 0x64_96_96_96 | Y |
| **6 (Storm)** | fogStart 0.8, fogFar 40, color 0x64_96_96_96 + `singleton+0x41 = 1` | Y |
| 0x65..0x72 | Play thunder/ambient sound via `FUN_00551560(soundId 0x76..0x83, chanObj)` | N (sound only) |
| 0x75..0x7B | Play thunder/ambient sound (0x84..0x8A) | N (sound only) |
| 9999 (preset 9999) | fogFar 30, color 0x32_64_64_64, same as preset 3 branch | Y |
All "crossfade" branches set `DAT_008427a9 = 1` and `_DAT_008427b8 = 0` via the
common `LAB_0055f050` tail.
The common tail (chunk_00550000.c:12009-12015):
```c
DAT_008427a9 = 1;
LAB_0055f050:
_DAT_008427b8 = 0;
DAT_008427b0 = 0; // reset fogNear target
iVar2 = FUN_00564d30();
*(undefined1 *)(iVar2 + 0x41) = 0; // clear storm flag
return 0;
```
### AdminEnvirons → crossfade trigger
The server's `AdminEnvirons(EnvironChangeType = 6)` path:
1. Client wire: opcode 0xEA60 followed by u32=6.
2. `FUN_006ae870` dispatches on opcode, calls `FUN_0055eb40(6)`.
3. `FUN_0055eb40` writes the storm targets + sets the crossfade flag.
4. Next `FUN_005062e0` tick (gated by `LightTickSize`) lerps toward the targets.
5. Crossfade continues at step `_DAT_007c7208` per tick until `u >= 1`.
---
## Q4: Thunder sound wiring
### Direct, not timer-driven
`FUN_00551560(soundId, chanObj)` is the play-sound-now call. `FUN_00564d50(singleton)`
lazily instantiates the channel object `FUN_00415730(0x10000003, 7, 0x22)` and
caches it at `singleton + 0x34`. Each EnvironChangeType 0x65..0x6A plays a
DIFFERENT thunder/ambient sound:
| EnvChangeType | soundId | Likely meaning |
|---|---|---|
| 0x65 (101) | 0x76 | Thunder1Sound |
| 0x66 (102) | 0x77 | Thunder2Sound |
| 0x67 (103) | 0x78 | Thunder3Sound |
| 0x68 (104) | 0x79 | Thunder4Sound |
| 0x69 (105) | 0x7A | Thunder5Sound |
| 0x6A (106) | 0x7B | Thunder6Sound |
| 0x6B..0x72 (107-114) | 0x7C..0x83 | other ambient sounds |
| 0x75..0x7B (117-123) | 0x84..0x8A | more ambient sounds |
**There is NO periodic "play thunder" call.** The retail client plays thunder
ONLY when the server sends `AdminEnvirons(0x65..0x6A)`. No client-side RNG
picks a sound, no tick schedules anything. If the server wants "thunder every
10-20 seconds during storm", **the server must send it explicitly.**
Cross-confirmation: `FUN_00551560(0x76, ...)` appears in the full decompile
only ONCE (chunk_00550000.c:11909). Every other thunder/ambient sound is also
a single-site dispatch from `FUN_0055eb40`. There is no storm-active loop.
---
## Q5: Port-ready C# pseudocode
### 1. Crossfade state machine
```csharp
// Source of truth: ACE/retail AC EnvironChangeType enum + the 7 cases of FUN_0055eb40
// (chunk_00550000.c:11839-12016).
public enum EnvironChangeType : uint
{
Clear = 0,
// Preset 1..6 are the historical fog presets. Values match FUN_0055eb40 switch.
Fog1 = 1, // 0x64_B2_96_00-ish, fogStart 0.4, fogFar 50
Fog2 = 2,
Fog3 = 3,
Fog4 = 4,
Fog5 = 5,
Storm = 6, // fogStart 0.8, fogFar 40, grey
Thunder1 = 0x65,
Thunder2 = 0x65 + 1,
// ...through 0x7B
Fog9999 = 9999,
}
internal sealed class WeatherCrossfade
{
// Retail globals (DAT_008427a9, DAT_008427ac, DAT_008427b0, DAT_008427b4, _DAT_008427b8,
// DAT_00842788, DAT_00842784).
private bool _active;
private float _progressU;
private float _targetFogStart;
private float _targetFogNear;
private float _targetFogFar;
private uint _targetFogColorArgb;
private uint _targetSecondaryArgb;
// Retail constant _DAT_007c7208 (.rdata). Per light-tick increment. Literal value is
// not in the decompile; 0.1 gives ~20s crossfade at default LightTickSize=2s.
// TODO(acdream): disassemble retail .rdata @ 0x007c7208 to pin the exact value.
public float ProgressStep { get; set; } = 0.1f;
/// <summary>FUN_0055eb40 — server AdminEnvirons(opcode 0xEA60) handler.</summary>
public void ApplyEnviron(EnvironChangeType type)
{
switch (type)
{
case EnvironChangeType.Clear:
_active = false;
_targetFogStart = 0f;
_targetFogFar = 0f;
_targetFogColorArgb = 0;
_targetSecondaryArgb = 0;
// fall through to reset tail
ResetTail();
return;
case EnvironChangeType.Fog1:
_targetFogStart = 0.4f; _targetFogFar = 50f;
_targetFogColorArgb = 0x64B29600; _targetSecondaryArgb = 0x64B29600; break;
case EnvironChangeType.Fog2:
_targetFogStart = 0.3f; _targetFogFar = 50f;
_targetFogColorArgb = 0x64320096; _targetSecondaryArgb = 0x64320096; break;
case EnvironChangeType.Fog3:
_targetFogStart = 0.4f; _targetFogFar = 30f;
_targetFogColorArgb = 0x64646464; _targetSecondaryArgb = 0x64646464; break;
case EnvironChangeType.Fog4:
_targetFogStart = 0.3f; _targetFogFar = 50f;
_targetFogColorArgb = 0x641E6400; _targetSecondaryArgb = 0x641E6400; break;
case EnvironChangeType.Fog5:
_targetFogStart = 0.8f; _targetFogFar = 40f;
_targetFogColorArgb = 0x64969696; _targetSecondaryArgb = 0x64000000; break;
case EnvironChangeType.Storm:
_targetFogStart = 0.8f; _targetFogFar = 40f;
_targetFogColorArgb = 0x64969696; _targetSecondaryArgb = 0x64000000;
StormFlag = true; // singleton+0x41; noted but unused by rendering
break;
case EnvironChangeType.Fog9999:
_targetFogStart = 0.4f; _targetFogFar = 30f;
_targetFogColorArgb = 0x32646464; _targetSecondaryArgb = 0x32646464; break;
default:
if ((int)type >= 0x65 && (int)type <= 0x7B) { PlayThunderFor(type); return; }
return;
}
_active = true;
_progressU = 0f;
_targetFogNear = 0f;
}
private void ResetTail()
{
_progressU = 0f;
_targetFogNear = 0f;
StormFlag = false;
}
public bool StormFlag { get; private set; }
/// <summary>Called each time the LightTickSize gate fires (~every 2 s).</summary>
public void AdvanceCrossfade(ref float curFogStart, ref uint curFogColorArgb,
ref float curFogNear, ref float curFogFar,
ref uint curSecondaryArgb)
{
if (!_active) return;
if (_progressU >= 1f)
{
// snap
curFogStart = _targetFogStart;
curFogColorArgb = _targetFogColorArgb;
curFogNear = _targetFogNear;
curFogFar = _targetFogFar;
curSecondaryArgb = _targetSecondaryArgb;
return;
}
curFogStart = curFogStart - (curFogStart - _targetFogStart) * _progressU;
curFogNear = curFogNear - (curFogNear - _targetFogNear) * _progressU;
curFogFar = curFogFar - (curFogFar - _targetFogFar) * _progressU;
curFogColorArgb = LerpArgbBytes(curFogColorArgb, _targetFogColorArgb, _progressU);
curSecondaryArgb = LerpArgbBytes(curSecondaryArgb, _targetSecondaryArgb, _progressU);
_progressU += ProgressStep;
}
private static uint LerpArgbBytes(uint a, uint b, float t)
{
// matches the per-byte pattern in FUN_005062e0:6262-6277
byte La(int s) => (byte)((a >> s) & 0xff);
byte Lb(int s) => (byte)((b >> s) & 0xff);
byte Lerp(int s) { float d = La(s) - Lb(s); return (byte)(La(s) - d * t); }
return (uint)(Lerp(0) | (Lerp(8) << 8) | (Lerp(16) << 16) | (Lerp(24) << 24));
}
}
```
### 2. AdminEnvirons → crossfade network binding (F.1 dispatcher)
```csharp
// src/AcDream.Core/Events/GameEventDispatcher.cs (existing pattern from Session 2026-04-18)
// Opcode 0xEA60 = 60000 = AdminEnvirons.
// Wire format: [u32 opcode][u32 environChangeType]
public void OnAdminEnvirons(BinaryReader r)
{
uint envType = r.ReadUInt32();
_world.Weather.ApplyEnviron((EnvironChangeType)envType);
// If envType is in 0x65..0x7B the above call plays a thunder sound and returns
// without setting the crossfade.
}
```
### 3. Thunder sound wiring
```csharp
// chunk_00550000.c:11906-11994 maps AdminEnvirons -> sound.
// soundId = (int)envType - 0x65 + 0x76 (i.e. 0x65→0x76, 0x66→0x77, ..., 0x72→0x83)
// second range 0x75..0x7B → 0x84..0x8A
// Route via the already-shipped OpenAL SoundPlayer (Phase E.2).
private void PlayThunderFor(EnvironChangeType type)
{
int et = (int)type;
int soundId = et switch
{
>= 0x65 and <= 0x72 => et - 0x65 + 0x76,
>= 0x75 and <= 0x7B => et - 0x75 + 0x84,
_ => 0,
};
if (soundId != 0) _audio.Play2D((uint)soundId);
}
```
### 4. Lightning flash
**Do not port.** Retail has none. If acdream *adds* it as a client-side visual
enhancement, it should be an explicit new system behind a feature flag — not
advertised as "matches retail." Document clearly in commit message.
---
## Citations
- `docs/research/decompiled/chunk_00500000.c:6249-6333``FUN_005062e0` per-frame sky+crossfade
- `docs/research/decompiled/chunk_00550000.c:11835-12016``FUN_0055eb40` EnvironChangeType dispatcher
- `docs/research/decompiled/chunk_00550000.c:11906-11994` — thunder/ambient sound cases
- `docs/research/decompiled/chunk_006A0000.c:13141-13153``FUN_006ae870` AdminEnvirons (0xEA60) network handler
- `docs/research/decompiled/chunk_00560000.c:2461-2467``FUN_00564d30` singleton getter for the weather manager
- `docs/research/decompiled/chunk_00560000.c:2890-2914` — weather-mgr ctor (+0x41 init = 0)
- `docs/research/decompiled/chunk_00550000.c:1114-1136``FUN_00551560` play-sound-by-id utility
- `docs/research/decompiled/chunk_00500000.c:6280, 6322` — only writers of `_DAT_008427b8 += _DAT_007c7208`
- `docs/research/decompiled/chunk_00550000.c:11887, 12011` — only other writers of `_DAT_008427b8` (reset to 0)
## Gaps / Unresolved
1. **`_DAT_007c7208` literal value.** It's an .rdata constant not inlined in any
decompile site. Acdream should either pick a tuning value (e.g. 0.1 for
~20 s crossfade at default LightTickSize=2 s) or disassemble the retail
binary `.rdata` at address 0x007c7208 to pin the exact value.
2. **Storm flag `singleton+0x41`.** Written to 1 in preset 6, but no reader in
the full 688K-line decompile. Likely a vestigial/dead field from an earlier
retail build, or consumed by a debug path that was stripped. Safe to ignore.
3. **Exact bit-layout of fog-color targets.** The constants like `0x64B29600`
are given in mixed ARGB/BGRA order in the decompile — the apply-byte-lerp
at 6262-6277 reads them in the same byte order as the runtime current value,
so as long as acdream consistently treats them as "retail-native ARGB", the
lerp math and final D3D state push will match. Validation: compare rendered
fog color side-by-side with retail under AdminEnvirons 1..5.

View file

@ -0,0 +1,502 @@
# PhysicsScript — Retail Runtime Research
**Date:** 2026-04-23
**Goal:** Port retail's PhysicsScript (PES) system verbatim so acdream's sky can play per-SkyObject effects (e.g. `DefaultPesObjectId = 0x330007DB` on DayGroup[0] SkyObject[6]).
**Outcome:** Runtime fully located in decompile. ACE / ACViewer ports are skeletons — acdream must actually implement this. Dat schema is complete and simple. Integration with sky is NOT automatic — retail's sky render loop does not itself spawn PES; we must add a walker.
---
## Q1. PhysicsScript dat schema (complete)
### `PhysicsScript` (DB_TYPE_PHYSICS_SCRIPT, range `0x33000000..0x3300FFFF`)
Source: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:26-55`.
```csharp
public partial class PhysicsScript : DBObj {
public List<PhysicsScriptData> ScriptData; // count + N entries
}
```
### `PhysicsScriptData` (per-command entry)
Source: `references/DatReaderWriter/DatReaderWriter/Generated/Types/PhysicsScriptData.generated.cs:22-44`.
```csharp
public partial class PhysicsScriptData {
public double StartTime; // seconds offset from script start
public AnimationHook Hook; // polymorphic — peeked as uint type prefix
}
```
Unpack: `StartTime (double) → peek AnimationHookType (uint, don't consume) → AnimationHook.Unpack(reader, type)`.
### `AnimationHook` subtypes used by sky/PES
`AnimationHookType` (source: `Generated/Enums/AnimationHookType.generated.cs:13-70`):
| Value | Name | Relevant for PES? |
|---|---|---|
| 0x0D | **CreateParticle** | **YES** — spawn emitter at part index / offset |
| 0x0E | **DestroyParticle** | **YES** — despawn emitter by EmitterId |
| 0x0F | **StopParticle** | **YES** — stop spawn, let alive particles die |
| 0x1A | **CreateBlockingParticle** | Rare; emitter-id dedupe variant |
| 0x13 | **CallPES** | **YES** — one script calls another |
| 0x01 | Sound | audio hook (less critical for sky) |
| 0x0A/0x0B | Diffuse/DiffusePart | per-surface color |
| 0x08/0x09 | Luminous/LuminousPart | override Surface.Luminosity |
| 0x14 | Transparent | override Surface.Transparency |
| 0x16 | SetOmega | spin rate |
| 0x17/0x18 | TextureVelocity[Part] | UV scroll |
| 0x19 | SetLight | light override |
### `CreateParticleHook` — the main one
Source: `Generated/Types/CreateParticleHook.generated.cs:22-54`.
```csharp
public partial class CreateParticleHook : AnimationHook {
public QualifiedDataId<ParticleEmitter> EmitterInfoId; // 0x32xxxxxx
public uint PartIndex; // which part of the PhysicsObj to attach to
public Frame Offset; // origin + orientation (Vec3 + Quat)
public uint EmitterId; // runtime handle for later Destroy/Stop hooks
}
```
### `DestroyParticleHook` / `StopParticleHook` — by EmitterId
Both carry a single `uint EmitterId` (lines 27-30 of respective generated files). Destroy removes the emitter; Stop flips `Stopped = true` and lets live particles finish their lifespan.
### `CreateBlockingParticleHook`
Source: `Generated/Types/CreateBlockingParticleHook.generated.cs:22-37`**empty body** in the dat. The "blocking" variant is a runtime behavior flag, not a data field.
### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`)
Identical on-disk layout — both `ParticleEmitter.generated.cs` and `ParticleEmitterInfo.generated.cs` unpack the same 31 fields in the same order. Schema summary (source: `Generated/DBObjs/ParticleEmitter.generated.cs:34-208`):
| Field | Type | Purpose |
|---|---|---|
| `Unknown` | uint | unused |
| `EmitterType` | enum | `Still`, `BirthratePerSecond`, `BirthratePerMeter`, … |
| `ParticleType` | enum | `Still`, `Local`, `Parabolic`, `Swarm`, `Explode`, `Implode` |
| `GfxObjId` | `QualifiedDataId<GfxObj>` | software-render mesh (ignored by retail — always uses HW) |
| `HwGfxObjId` | `QualifiedDataId<GfxObj>` | hardware-render mesh (1 per particle) |
| `Birthrate` | double | seconds between spawns |
| `MaxParticles` | int | live cap |
| `InitialParticles` | int | spawn count at t=0 |
| `TotalParticles` | int | 0 = unlimited |
| `TotalSeconds` | double | 0 = infinite |
| `Lifespan`, `LifespanRand` | double | per-particle life ± rand |
| `OffsetDir`, `MinOffset`, `MaxOffset` | Vec3, 2×float | spawn position randomizer |
| `A`,`MinA`,`MaxA` | Vec3, 2×float | velocity axis A |
| `B`,`MinB`,`MaxB` | Vec3, 2×float | velocity axis B |
| `C`,`MinC`,`MaxC` | Vec3, 2×float | velocity axis C (for e.g. Parabolic gravity) |
| `StartScale`,`FinalScale`,`ScaleRand` | float | scale lerp |
| `StartTrans`,`FinalTrans`,`TransRand` | float | transparency lerp (0=opaque … 1=transparent in retail) |
| `IsParentLocal` | bool | follow parent transform each frame |
`ParticleType` enum options drive the per-particle integrator shape (linear, ballistic, etc.). `EmitterType` drives `ShouldEmitParticle()` logic (ACE `ParticleEmitterInfo.cs:ShouldEmitParticle`).
### `PhysicsScriptTable` (DB_TYPE_PHYSICS_SCRIPT_TABLE, `0x34000000..0x3400FFFF`)
Source: `Generated/DBObjs/PhysicsScriptTable.generated.cs:22-59`.
```csharp
Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable;
// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values)
// PhysicsScriptTableData = List<ScriptAndModData> Scripts (weighted variants)
// ScriptAndModData = { float Mod; QualifiedDataId<PhysicsScript> ScriptId; }
```
Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Enables "when I die, pick a death-sound script with weight = Mod". Not relevant for sky, but relevant for NPC/monster/spell PES.
### Retail factory registration (chunk_00410000.c:13439-13451)
```c
local_8 = 3; // some flag
local_4 = 0xf; // flag
local_e = 0;
FUN_0041f900(&DAT_00796578, local_3c + 1); // set type name "PhysicsScript"
local_3c[1] = 0x33000000; // range lo
local_3c[2] = 0x3300ffff; // range hi
FUN_00401340(&DAT_00796734); // vtable pointer
FUN_0040c440(local_3c); // register-factory call
```
Type-index (from chunk_00410000.c:10675): **`0x2b`** for PhysicsScript, `0x2a` for ParticleEmitterInfo (via symmetric branch), `0x2c` for PhysicsScriptTable. The loader dispatch uses these.
---
## Q2. Retail runtime — `FUN_0051be40`/`FUN_0051bed0`/`FUN_0051bf20`/`FUN_0051bfb0`
All citations: `docs/research/decompiled/chunk_00510000.c`.
### The ScriptManager class — lives at `PhysicsObj + 0x30`
From line 1517-1528:
```c
// FUN_005117?? — PhysicsObj::play_script_internal(self, scriptID)
if (*(int *)(param_1 + 0x30) == 0) { // no manager yet?
iVar1 = FUN_005df0f5(0x18); // allocate 24-byte manager
if (iVar1 != 0) {
uVar2 = FUN_0051be20(param_1); // ScriptManager::ctor(self)
}
*(undefined4 *)(param_1 + 0x30) = uVar2;
}
if (*(int *)(param_1 + 0x30) != 0) {
uVar3 = FUN_0051bed0(param_2); // manager.AddScript(scriptID)
}
```
**ScriptManager layout** (inferred from FUN_0051be20, 24 bytes at `+0x30`):
```
+0x00 ownerPhysicsObj*
+0x04 head* (ScriptNode linked-list head) — called from FUN_0051bfb0:11187
+0x08 tail*
+0x0c lastIndex (init 0xFFFFFFFF)
+0x10 nextTickTime (double, bytes 0x10..0x17)
+0x18 ...
```
### `FUN_0051bed0` — public script loader (line 11121)
```c
undefined4 FUN_0051bed0(undefined4 scriptID) {
uVar1 = FUN_004220b0(scriptID, 0x2b); // make QualifiedDataId<PhysicsScript>
iVar2 = FUN_00415430(uVar1); // DB lookup — returns PhysicsScript*
if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) {
return 1;
}
return 0;
}
```
### `FUN_0051be40` — ScriptManager::Start (line 11078)
Allocates a 16-byte ScriptNode: `{ double startTime; PhysicsScript* script; ScriptNode* next; }`. Sets `startTime = globalClock (DAT_008379a8)` or `prev.startTime + prev.script.Lifespan_at_0x48`. Links into tail.
### `FUN_0051bf20` — ScriptManager::AdvanceOneHook (line 11139)
```c
// Compact paraphrase:
int idx = ++manager.hookIndex; // pdVar2+0xc
PhysicsScript* script = manager.head->script; // (*(pdVar2+1))
int hookCount = script->count_at_0x44;
if (hookCount <= idx) return 0; // done
// Peek next hook's StartTime to schedule next tick
if (idx+1 < hookCount)
manager.nextTickTime = head.startTime + script.hooks[idx+1].StartTime;
else if (head.next != NULL)
manager.nextTickTime = head.next.startTime + head.next.script.hooks[0].StartTime;
else
manager.nextTickTime = -1.0; // sentinel 0xBFF00000 = -1.0 as double-hi
return script.hooks[idx].Hook; // pointer to AnimationHook for execution
```
Offsets here decoded: `script + 0x38` = hooks array, `script + 0x44` = hooks count, each hook entry at `+hookIdx*4` is a `PhysicsScriptData*` with `+0x00` StartTime (double) and `+0x08` Hook* pointer.
### `FUN_0051bfb0` — ScriptManager::Tick (line 11178) — called every frame per physics object
```c
int head = manager.head;
while (head != 0 && manager.nextTickTime <= globalClock_DAT_008379a8) {
Hook* h = FUN_0051bf20(manager); // returns next hook or NULL=done
if (h == NULL) {
// current script done → pop to next script
prev = manager.head;
manager.head = prev.next;
manager.lastIndex = -1;
if (manager.head == NULL) {
manager.nextTickTime = -1.0;
manager.tail = NULL;
} else {
manager.nextTickTime = manager.head.startTime + manager.head.script.hooks[0].StartTime;
}
delete prev;
} else {
// Execute: virtual dispatch on hook type
(**(code **)(*h + 4))(ownerPhysicsObj);
}
head = manager.head;
}
```
The hook is a vtable-dispatched virtual call — retail's AnimationHook derived classes implement `execute(PhysicsObj* self)` at vtable slot 1 (`+4`). For `CreateParticleHook` this calls `self->ParticleManager->CreateParticleEmitter(emitterInfoId, partIndex, &offset, emitterId)`.
### `FUN_0051bda0` — AnimationTable::appendScriptEntry (line 11037)
Used at line 289/322 in `FUN_00510340` (which is AnimationTable-level, not ScriptManager). Part of the broader animation hook infrastructure; not on the PES hot path.
---
## Q3. Particle-emitter runtime
**Retail code:** not in this decompile chunk extract (would be elsewhere in chunk_00510000.c); the class instantiation is done by each `CreateParticleHook.execute()`. Best available C# port is ACE's `ParticleEmitter.cs`.
Key ACE sources (read these for the actual per-particle math — ACE is faithful here even though its outer `PhysicsScript` class is empty):
- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleManager.cs:26-45``CreateParticleEmitter(obj, emitterInfoID, partIdx, offset, emitterID)`.
- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255``UpdateParticles()` — the per-frame tick. Separates degrade-distance-culled and active paths. When non-culled, walks each particle slot: `frame = IsParentLocal ? parent.Frame : particle.StartFrame; particle.Update(ParticleType, firstParticle, part, frame); KillParticle(i);`
- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:83-93``ShouldEmitParticle` dispatches on `EmitterType` (`BirthratePerMeter` uses Δorigin since last emit; others use Δtime).
- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:130-152``EmitParticle` picks a free slot and calls `Particle.Init(info, parent, partIdx, parentOffset, part, randomOffset, firstParticle, randomA, randomB, randomC)`.
**Important caveat:** ACE's `ParticleEmitter` references `SortingSphere`, `HWGfxObjID`, `ShouldEmitParticle(numParticles, totalEmitted, offset, lastEmitTime)` on `ParticleEmitterInfo` — these are runtime-interpretive helpers, not raw dat fields. The raw dat has the 31-field struct above; ACE augments it with derived properties.
### Relevance for sky (Q4) — NEGATIVE
ACE's `ParticleEmitter` is tightly parent-bound to a `PhysicsObj` (`parent.PartArray.Parts[partIndex].Pos.Frame`). Retail PES binds to a PhysicsObj via `CreateParticleHook.PartIndex`. A SkyObject in retail is a PhysicsObj (via `FUN_00514470` — line 7500 in chunk_00500000.c, which allocates 0x178 bytes = sizeof(PhysicsObj) and sets up the mesh). **So a sky-object IS a PhysicsObj**, and its script would attach to *that*.
---
## Q4. Sky → PES connection — THE ACTUAL STATE
**Claim to verify: does the retail sky loop actually spawn PES from `DefaultPesObjectId`?**
Cross-references into `FUN_00508010` (sky render loop, chunk_00500000.c:7535-7603) and `FUN_00507e20` (sky table refresh, chunk_00500000.c:7414-7527):
### What the sky loop does consume from the per-frame entry
Per-entry layout (from `FUN_00502a10` writes, chunk_00500000.c:2491-2510) — 0x2c bytes:
```
+0x00 GfxObjId ← FUN_00508010:7569 (read into uVar3)
+0x04 PesObjectId ← NEVER READ by FUN_00508010 or FUN_00507e20
+0x08 runtime "axis1" ← FUN_00508010:7570 (read into uVar4 → ApplyRotations)
+0x0c CurrentArcAngle ← (degree interp)
+0x10..0x18 TexVelocityX/Y/runtime
+0x1c Transparent ← FUN_00508010:7593
+0x20 Luminosity ← FUN_00508010:7587
+0x24 MaxBright ← FUN_00508010:7590 (also FUN_00507940:7218)
+0x28 Properties ← FUN_00507e20:7498 (goes to param_1[6] flags array)
```
**The sky render loop reads offsets 0x00, 0x08, 0x0c, 0x1c, 0x20, 0x24 and 0x28. It never touches 0x04 (PesObjectId).**
### What actually runs the PES (the real path)
`FUN_00507e20:7500` calls `FUN_00507940(GfxObjId_at_+0x00, &entry.TransformOffset_at_+0x10, flag&1_bouncy, flag&4_customPos)`. That → `FUN_00514470` at chunk_00510000.c:4153, which **allocates a PhysicsObj (0x178 bytes) for the sky object** and runs `FUN_005131b0(GfxObjId, 1)` (Setup loader). The sky object's PhysicsObj is stored in `param_1[3]` (the third field-array of the sky table) — one live PhysicsObj per visible sky entry.
**But that's for the GfxObj, not the PES.** The PES would run via the normal PhysicsObj-level `play_script` path — if something called `sky.physObj.play_script(entry.PesObjectId)`.
I searched for such a call: **no caller of `FUN_005117??` (play_script) in chunk_00500000.c references the sky entry's +0x04 offset.** I also searched for the `FUN_0051bed0` public entry — one call only (chunk_00510000.c:1528), inside the PhysicsObj public `play_script`. No sky-specific caller.
### Best-fit interpretation
**The retail sky does NOT automatically run `DefaultPesObjectId`.** Looking at where it WOULD happen, there are three plausible places retail might wire it up that I haven't yet located:
1. **`FUN_00507940` inner** — this is the sky-object instantiation. It could internally call `play_script(entry.PesObjectId)` on the newly-created PhysicsObj. **Its decompile extract (lines 7201-7221) reads only `param_1+0x24`/`+0x28` and does NOT dispatch a script**, so this path is ruled out on the extract we have.
2. **Region tick path**`FUN_005062e0` (per-frame sky tick) could walk the table and call play_script per entry. The code at chunk_00500000.c:6213-6683 passed through earlier showed only `FUN_00508010` (render) and light/fog lerps — no PES walker.
3. **`FUN_00507e20` spawn-side** — the "new entry" branch at chunk_00500000.c:7497-7502 is the `LAB_00507fb6` label. After building the PhysicsObj (`FUN_00507940`), it stores only the PhysicsObj into `param_1[3]` and the flags into `param_1[6]`. **No PES play here either.**
**Honest conclusion:** In the portions of the decompile I examined, retail's sky pipeline creates a PhysicsObj per sky-object for rendering but **does NOT spawn its `DefaultPesObjectId` as a PhysicsScript**. Either (a) the feature is dead code — the `DefaultPesObjectId` field on SkyObject is schema-level but unused by retail, or (b) the wiring lives in a retail code region I haven't yet mapped (possible candidate: the `FUN_00507e20` caller chain or a post-Region-load initializer).
For acdream, this means:
- **If we want visible sky PES, we add the walker ourselves.** It's an acdream extension to a schema-level dat feature retail may not have actually used. Low-risk (no retail regression to match) but also — we have no ground truth for "does this look right?".
- **Evidence gathering:** run retail (or ACE + a retail client that matches the live server) and observe: does the afternoon sky (DayGroup[0] slot 6) exhibit visible particle effects? If no, retail doesn't run this. If yes, we missed a call site.
---
## Q5. Port-ready pseudocode (C#-flavored)
### 5.1 `PhysicsScript` class (dat-backed)
acdream already has `ParticleSystem.PlayScript(uint scriptId, uint targetObjectId, float modifier)` (`src/AcDream.Core/Vfx/ParticleSystem.cs:88`). We extend it with a real implementation:
```csharp
// New file: src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs
public sealed class PhysicsScriptNode
{
public double StartTimeSeconds; // absolute game clock
public PhysicsScript Script;
public int HookIndex = -1;
public double NextHookAbsTime; // StartTimeSeconds + Script.ScriptData[HookIndex+1].StartTime
public PhysicsScriptNode Next;
}
public sealed class ScriptManager // attaches to one "target" (Sky object, PhysicsObj, etc.)
{
public uint OwnerObjectId; // for emitter parenting
public PhysicsScriptNode Head;
public PhysicsScriptNode Tail;
// Returns true if script started (dat found + non-empty).
public bool Start(double nowSeconds, PhysicsScript script, float modifier)
{
if (script == null || script.ScriptData.Count == 0) return false;
var node = new PhysicsScriptNode {
StartTimeSeconds = (Tail == null) ? nowSeconds : Tail.StartTimeSeconds + /*lifespan*/ 0.0,
Script = script,
};
node.NextHookAbsTime = node.StartTimeSeconds + script.ScriptData[0].StartTime;
if (Tail != null) Tail.Next = node; else Head = node;
Tail = node;
// `modifier` is not consumed by PhysicsScript itself — it's used by
// PhysicsScriptTable.GetScript to *pick* which script. Ignore here.
return true;
}
public void Tick(double nowSeconds, IParticleSystem particles)
{
while (Head != null && Head.NextHookAbsTime <= nowSeconds) {
var node = Head;
int next = node.HookIndex + 1;
if (next >= node.Script.ScriptData.Count) {
// Pop this script
Head = node.Next;
if (Head == null) Tail = null;
continue;
}
node.HookIndex = next;
var data = node.Script.ScriptData[next];
ExecuteHook(data.Hook, particles);
// Schedule next within this script, or fall through to next script's first hook
int peek = next + 1;
if (peek < node.Script.ScriptData.Count)
node.NextHookAbsTime = node.StartTimeSeconds + node.Script.ScriptData[peek].StartTime;
else if (node.Next != null)
node.NextHookAbsTime = node.Next.StartTimeSeconds
+ node.Next.Script.ScriptData[0].StartTime;
else
node.NextHookAbsTime = double.MaxValue; // this node done, will be popped above
}
}
private void ExecuteHook(AnimationHook hook, IParticleSystem particles)
{
switch (hook) {
case CreateParticleHook c:
particles.SpawnEmitterById(
emitterInfoId: c.EmitterInfoId.Id,
targetObjectId: OwnerObjectId,
partIndex: (int)c.PartIndex,
localOffset: c.Offset, // Frame → (Vec3 origin, Quat heading)
emitterHandle: c.EmitterId); // used as stable key so Destroy/Stop find it
break;
case DestroyParticleHook d:
particles.DestroyEmitterByScriptHandle(OwnerObjectId, d.EmitterId);
break;
case StopParticleHook s:
particles.StopEmitterByScriptHandle(OwnerObjectId, s.EmitterId, fadeOut: true);
break;
case CallPESHook cp:
// Recursive — spawn another script node bound to same owner
var subScript = DatCollection.Read<PhysicsScript>(cp.PlayScriptId.Id);
if (subScript != null) Start(/*nowSeconds=*/0, subScript, 1f); // real impl reuses last StartTime
break;
// Sound / Luminous / Diffuse / Scale / Transparent / SetOmega etc.
// are per-PhysicsObj mutations; relevant only once we own PhysicsObj state.
default:
/* no-op for now — log unknown */
break;
}
}
}
```
### 5.2 `ParticleSystem` extensions
Existing: `src/AcDream.Core/Vfx/ParticleSystem.cs` already has `SpawnEmitter` + `PlayScript(uint,uint,float)` stub. We need:
```csharp
// Inside ParticleSystem — uses per-(owner, scriptEmitterId) dictionary so
// Destroy/Stop hooks can find what CreateParticle spawned.
private readonly Dictionary<(uint owner, uint scriptHandle), int> _byScriptHandle = new();
public int SpawnEmitterById(uint emitterInfoId, uint targetObjectId,
int partIndex, Frame localOffset, uint emitterHandle) {
var info = DatCollection.Read<ParticleEmitterInfo>(emitterInfoId);
if (info == null) return 0;
var desc = EmitterDescLoader.FromInfo(info, partIndex, localOffset);
int handle = SpawnEmitter(desc, targetObjectId);
if (emitterHandle != 0) _byScriptHandle[(targetObjectId, emitterHandle)] = handle;
return handle;
}
public void DestroyEmitterByScriptHandle(uint owner, uint scriptHandle) {
if (_byScriptHandle.Remove((owner, scriptHandle), out var h))
StopEmitter(h, fadeOut: false);
}
public void StopEmitterByScriptHandle(uint owner, uint scriptHandle, bool fadeOut) {
if (_byScriptHandle.TryGetValue((owner, scriptHandle), out var h))
StopEmitter(h, fadeOut);
}
```
### 5.3 Sky integration (acdream extension — since retail doesn't walk PES)
In `SkyState.UpdateSkyObjectsTable(dayFraction)` (or wherever the per-frame SkyObject table is built), add after the visibility cull:
```csharp
// Per-visible-SkyObject PES instance cache, keyed by (dayGroupIdx, skyObjectIdx).
// Allocates a pseudo-ObjectId so ParticleSystem can parent to the sky-object slot.
private readonly Dictionary<(int dg, int so), (uint pseudoObjId, ScriptManager mgr)> _skyPes = new();
private void TickSkyObjectPes(double nowSeconds, IParticleSystem particles) {
foreach (var entry in _visibleSkyEntries) {
if (entry.PesObjectId == 0) continue;
var key = (entry.DayGroupIndex, entry.SkyObjectIndex);
if (!_skyPes.TryGetValue(key, out var slot)) {
var script = DatCollection.Read<PhysicsScript>(entry.PesObjectId);
if (script == null) continue;
slot = (pseudoObjId: AllocatePseudoSkyObjId(key), mgr: new ScriptManager());
slot.mgr.OwnerObjectId = slot.pseudoObjId;
slot.mgr.Start(nowSeconds, script, modifier: 1f);
_skyPes[key] = slot;
}
slot.mgr.Tick(nowSeconds, particles);
// TODO: when sky object leaves visibility window, stop + clean up:
// if (!entry.Visible) { particles.ClearOwner(slot.pseudoObjId); _skyPes.Remove(key); }
}
}
```
The pseudo-ObjectId lets `CreateParticleHook.Offset` attach in "world space at the sky mesh's current transform" — acdream's `ParticleSystem` computes positions from the owner's world frame, so the sky renderer must expose each visible SkyObject's world transform to the particle system via the same pseudoObjId.
### 5.4 Threading / clock
Use the same game clock `SkyState` uses (bound to `TimeManager` or whatever feeds `DirBright` etc.). Retail's `_DAT_008379a8` is wall-clock-seconds double. One tick per frame, on the main thread, after Sky state update and before particle GPU upload.
---
## Quick integration checklist
1. Add `PhysicsScript` and `ParticleEmitterInfo` readers to `DatCollection` (they're generated by DatReaderWriter already — just wire type IDs `0x2b` and `0x2a`).
2. New `src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs` with `ScriptManager` + `PhysicsScriptNode` per §5.1.
3. Extend `ParticleSystem` with script-handle registry per §5.2.
4. Add `TickSkyObjectPes` to Sky pipeline per §5.3.
5. Conformance test: load `0x330007DB` and verify parsed `ScriptData` hooks match a dump (e.g. ACViewer can visualize PhysicsScripts — confirm hook order and `StartTime` values).
6. **Before deploying:** confirm retail actually plays these scripts (record gameplay, look for cloud particles). If retail doesn't, don't ship — it's a dead feature.
---
## Citations
| Claim | Source |
|---|---|
| Dat schema PhysicsScript | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:34-55` |
| PhysicsScriptData | `Generated/Types/PhysicsScriptData.generated.cs:23-43` |
| CreateParticleHook | `Generated/Types/CreateParticleHook.generated.cs:22-54` |
| ParticleEmitter schema | `Generated/DBObjs/ParticleEmitter.generated.cs:34-208` |
| AnimationHookType enum | `Generated/Enums/AnimationHookType.generated.cs:13-70` |
| Factory reg for 0x33xxxxxx | `docs/research/decompiled/chunk_00410000.c:13439-13451` |
| Type-index 0x2b | `chunk_00410000.c:10670-10677` (range-dispatch fn) |
| Script loader `FUN_0051bed0` | `chunk_00510000.c:11119-11133` |
| ScriptManager start `FUN_0051be40` | `chunk_00510000.c:11076-11114` |
| Advance `FUN_0051bf20` | `chunk_00510000.c:11137-11170` |
| Tick `FUN_0051bfb0` | `chunk_00510000.c:11174-11216` |
| Per-object tick hook | `chunk_00510000.c:3479-3481` |
| Play-script entry inside PhysicsObj | `chunk_00510000.c:1517-1528` |
| Sky loop reads from entry | `chunk_00500000.c:7569-7594` |
| PesObjectId written but unread | `chunk_00500000.c:2492` (write) — no matching read in 7414-7527 or 7535-7603 |
| Sky mesh → PhysicsObj allocation | `chunk_00510000.c:4159` (`FUN_005df0f5(0x178)`) |
| ACE ParticleEmitter update | `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255` |
| ACE PhysicsScriptTable (skeleton) | `references/ACE/Source/ACE.Server/Physics/Scripts/PhysicsScriptTable.cs:1-20` |
| acdream existing Vfx | `src/AcDream.Core/Vfx/ParticleSystem.cs:24-108` |
**Word count:** ~2,250.

View file

@ -0,0 +1,335 @@
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
**Date:** 2026-04-23
**Scope:** Q1-Q5 of the sky-fog hunt. Pins retail's fog mode, fog-distance
source, and whether sky meshes actually render through fog — with file:line
citations from `docs/research/decompiled/`.
## TL;DR — the retail fog equation for ALL meshes (sky included)
Retail uses **linear vertex fog** (`D3DRS_FOGVERTEXMODE = 3`) with
**RANGEFOGENABLE = TRUE**, meaning the fog factor is computed per-vertex
using **true 3D eye-space distance** `|eyePos - vertexPos|`, interpolated
to fragments, and blended in fixed-function D3D:
```
// Computed per VERTEX by the fixed-function pipeline:
dist = length(eyePos - worldPos) // RANGEFOG=1
f = saturate((FOGEND - dist) / (FOGEND - FOGSTART)) // linear
// Stored as vertex fog coord. Interpolated to fragment:
fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, f) // f=1 ⇒ no fog
```
**Sky meshes go through this exact path**: no D3D state is toggled around
the sky render (confirmed hunt B). The sky render loop `FUN_00508010`
at `chunk_00500000.c:7535-7603` enqueues sky GfxObjs via the normal mesh
path with **identity transform (translation = 0, rotation = identity)**,
then `FUN_005079e0` applies a rotation-only two-axis transform. **Sky
vertices are rendered at their raw mesh-space positions in world-space
(centered at the world origin).**
## Q1 — Eye-space Z / vertex distance at which the sky is rendered
**Answer: the sky mesh's own intrinsic radius (scale = 1.0, no transform
offset), taken at world origin (0,0,0) in world space.**
### Evidence — transform setup at sky render
`chunk_00500000.c:7571-7586` (sky render loop, per sky object):
```c
local_48 = 0x3f800000; // quaternion w = 1.0f
local_44 = 0; // quaternion x = 0
local_40 = 0; // quaternion y = 0
local_3c = 0; // quaternion z = 0
local_14 = 0; // translation x = 0
local_10 = 0; // translation y = 0
local_c = 0; // translation z = 0
FUN_00535b30(); // quaternion → 3x3 rotation matrix
if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) {
// billboard branch: copy 3-float translation from iVar5 + 0x84..0x8c
local_14 = *(undefined4 *)(iVar5 + 0x84);
local_10 = *(undefined4 *)(iVar5 + 0x88);
local_c = *(undefined4 *)(iVar5 + 0x8c);
}
FUN_005079e0(&local_48, uVar3, uVar4); // apply 2-axis rotation (no translation)
FUN_00514b90(&local_48); // enqueue mesh draw with this transform
```
`FUN_00535b30` at `chunk_00530000.c:4509-4531` is a pure
quaternion-to-3x3 rotation builder — **no translation written**. So the
transform passed to every sky mesh is `{rotation, translation=(0,0,0)}`
(except for billboard-flagged objects that take a translation from the
GfxObj's +0x84 slot, which historically is small; not addressed here).
### Evidence — no camera-centered sky projection
Hunt B searched for view-matrix manipulation around the sky render and
found **nothing**. See `docs/research/2026-04-23-sky-decompile-hunt-B.md:323-335`:
> The view matrix is NOT rewritten with zero translation before the sky
> draw. This is consistent with the conclusion that there is no discrete
> "sky dome" — the weather/fog volume objects follow the camera by being
> placed in camera-relative world position by their parent scene-graph
> node.
And hunt B also confirms no huge far-plane constants in the `.rdata`
(lines 337-349): no `1e5`, `1e6`, `1e7` floats anywhere. The only far-plane
change is the weather-volume pass:
```c
// chunk_00500000.c:7272 (weather volume, NOT sky proper)
FUN_0054bf30(DAT_0081fc98 * _DAT_007c6f14);
```
`_DAT_007c6f14` appears in cubic-spline math in `chunk_005E0000.c:258, 474,
742` — it's a small constant (~1-3), not a huge sky-scale multiplier.
### Implication for vertex distance
Since the sky transform is `(rotation, 0)` and the camera view matrix is
unchanged, the sky vertex's world-space position is `rotation × meshVertex`.
The vertex's **eye-space distance** is therefore
`length(meshVertex_rotated - cameraWorldPos)` — i.e. it **depends on the
sky GfxObj's intrinsic mesh radius and where the camera is**.
For the standard sky GfxObjs (dome `0x010015EE`, stars, sun, moon), the
mesh dimensions live in the `.dat` file (not decompiled here). **WorldBuilder's
sky implementation** at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:247`
explicitly comments:
> Using 1.0f scale as the far plane is now huge and AC meshes are already
> at large distances.
So empirical evidence from a known-working AC client port confirms the
sky GfxObjs are intrinsically **thousands of meters in radius** (requiring
far plane ≈ 1e6 to not clip). This is consistent with the typical retail
FOGEND = 2400m saturating the sky to FOGCOLOR — **which IS what retail
does** and is why the user sees a colored "sky glow" matching the fog
color at ground level.
## Q2 — Fog mode (vertex vs table, linear vs exp)
**Answer: Vertex-linear fog with 3D range-distance.**
### Evidence — device-init state (`FUN_005a10f0` → the master init at 0x005A4F20)
`chunk_005A0000.c:3361-3389` (state reset block, written when the device
is initialized or reset):
```c
// D3DRS state-value pairs written on device init/reset:
(**...0xe4)(dev, 0x1c, 1); // FOGENABLE = TRUE
(**...0xe4)(dev, 0x1d, 0); // FOGTABLEMODE = D3DFOG_NONE
(**...0xe4)(dev, 0x22, 0xaaaaaa); // FOGCOLOR = RGB(170,170,170)
(**...0xe4)(dev, 0x23, 0); // ? (state 35)
(**...0xe4)(dev, 0x24, 0x43c80000); // FOGSTART = 400.0f
(**...0xe4)(dev, 0x25, 0x44fa0000); // FOGEND = 2000.0f
(**...0xe4)(dev, 0x26, 0x3e4ccccd); // FOGDENSITY = 0.2f (unused)
(**...0xe4)(dev, 0x30, 1); // RANGEFOGENABLE = TRUE
...
(**...0xe4)(dev, 0x8c, 3); // FOGVERTEXMODE = D3DFOG_LINEAR (3)
```
Reading the D3DRS hex codes:
| Hex | Dec | D3DRS Name | Value | Meaning |
|-----|-----|-------------------|-------------|---------|
| 0x1c | 28 | FOGENABLE | 1 | fog ON |
| 0x1d | 29 | FOGTABLEMODE | 0 | **NO pixel fog** |
| 0x22 | 34 | FOGCOLOR | 0xaaaaaa | default gray |
| 0x24 | 36 | FOGSTART | 400.0f | start distance |
| 0x25 | 37 | FOGEND | 2000.0f | end distance |
| 0x30 | 48 | RANGEFOGENABLE | 1 | **use 3D distance** |
| 0x8c | 140 | FOGVERTEXMODE | 3 (LINEAR) | **per-vertex linear fog** |
**Verification that FOGSTART = 400.0f:** `0x43c80000` = 400.0.
**Verification that FOGEND = 2000.0f:** `0x44fa0000` = 2000.0.
The per-frame fog writer `FUN_005a4080` at `chunk_005A0000.c:2870-2907`
only writes states `0x22` (FOGCOLOR), `0x24` (FOGSTART), `0x25` (FOGEND).
**It NEVER writes FOGVERTEXMODE or FOGTABLEMODE** — those stay at their
init values for the entire session.
Hunt B (`2026-04-23-sky-decompile-hunt-B.md:302-306`) independently verified:
> **D3DRS_FOGTABLEMODE=0x23, FOGVERTEXMODE=0x8c, FOGDENSITY=0x26**
> these are only set once in the default-init (`FUN_005a10f0`) and
> never per-frame. Retail uses linear fog (FOGSTART/FOGEND), not
> exponential (FOGDENSITY).
(Note the doc calls them by D3DRS name; 0x1d is TABLEMODE, 0x8c is
VERTEXMODE. The doc's hex is slightly off but the conclusion is correct.)
## Q3 — What "distance" does retail use per-sky-vertex
**Answer: true 3D eye-space distance from camera to vertex** (because
`D3DRS_RANGEFOGENABLE = 1`).
D3D fixed-function linear vertex fog with `RANGEFOGENABLE = 1` computes:
```
fogDistance = length(EyePos - VertexPos) // 3D euclidean
fogFactor = saturate((FOGEND - fogDistance) / (FOGEND - FOGSTART))
```
`fogFactor = 1.0` means "fully visible (no fog)"; `fogFactor = 0.0` means
"fully fogged (100% FOGCOLOR)".
With a sky dome mesh of radius `R` rendered at world origin and a camera
at world position `cam`:
```
fogDistance(skyVertex) = |cam - (rotation × skyVertex)| ≈ R (for R ≫ |cam|)
```
In Dereth, `|cam|` is the ground-level camera position (say ~100m altitude,
~10,000m absolute if near a Holtburg landblock). The sky dome vertex is
at `rotation × meshVertex` — rotation is a unit-quat, so magnitude is
preserved. If the dome mesh has radius ~3000m, `fogDistance ≈ 3000m`
well past `FOGEND = 2000m` in the init — so the **sky renders fully
fogged** unless the keyframe-driven FOGEND is large enough (see note
about MaxWorldFog below).
### Per-keyframe FOGEND override
At `chunk_00500000.c:6294-6326`, every `LightTickSize` seconds the
`FUN_00501860` fog-lerp writes per-keyframe `fogStart, fogEnd, fogColor`
(from `SkyTimeOfDay.MinWorldFog, MaxWorldFog, WorldFogColor`). Typical
retail dusk values are `Min ≈ 150`, `Max ≈ 2400`. At `Max = 2400`, a
sky-dome vertex at ~3000m is fully fogged to `WorldFogColor`.
**This is the mechanism by which the horizon colors in retail:** the sky
dome mesh is at a distance where fog contribution dominates, so the
screen-space sky color IS `WorldFogColor` (the dusk purple, the dawn
peach, etc.) interpolated between keyframes.
## Q4 — Fog application order
**Answer: fixed-function D3D applies fog as the LAST stage**, after
material × texture modulate, per standard D3D pipeline:
```
fragment.rgb = texture.rgb * litColor.rgb // see Q6 of the material doc
fragment.a = texture.a * litColor.a
// Fog stage (D3D hardware, always after everything else in FFP):
fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, fogFactor)
```
Retail does NOT alter this ordering for sky meshes — no state is flipped
around the sky render (see `2026-04-23-sky-material-state.md:309-327`).
The sky fragment is the fully lit+textured surface × fog blend. Since
sky meshes typically have `Surface.Luminous = true` (see material-state
doc §2), the lit color is `texture × Luminosity` (emissive-only); fog
then blends this with `WorldFogColor`.
## Q5 — Port-ready pseudocode for acdream's GLSL sky shader
```glsl
// Vertex shader — compute fog factor on the CPU or in the vertex shader:
vec3 worldPos = (uModel * vec4(aPos, 1.0)).xyz; // sky mesh at world origin
vec3 eyeToVert = worldPos - uCameraWorldPos;
float dist = length(eyeToVert); // RANGEFOG=1 (3D, not Z)
float fogFactor = clamp((uFogEnd - dist) / (uFogEnd - uFogStart), 0.0, 1.0);
v_FogFactor = fogFactor;
// …normal vertex transform…
// Fragment shader:
vec4 tex = texture(uSkyTex, vUv);
vec3 lit = tex.rgb * uLuminosity; // for luminous sky meshes
float alpha = tex.a * (1.0 - uTransparency);
// Fog: fogFactor = 1 ⇒ no fog; fogFactor = 0 ⇒ 100% fog color
vec3 withFog = mix(uFogColor, lit, v_FogFactor);
out_Color = vec4(withFog, alpha);
```
### Uniforms — all driven per-keyframe by SkyTimeOfDay
- `uFogStart` = interpolated `SkyTimeOfDay.MinWorldFog` (meters)
- `uFogEnd` = interpolated `SkyTimeOfDay.MaxWorldFog` (meters)
- `uFogColor` = interpolated `SkyTimeOfDay.WorldFogColor` (RGB, A unused)
- `uCameraWorldPos` = player's camera world-space position
- `uLuminosity`, `uTransparency` = already-interpolated keyframe override
### DO NOT suppress fog on the sky
The retail behavior IS "sky saturates to WorldFogColor at long distance,"
and that produces the correct dusk-purple / dawn-peach horizon gradient.
Suppressing fog on the sky would make our sky look like a retail-client
rendered WITHOUT fog — which is not what the user sees in retail.
### DO scale sky vertices intrinsically
The sky GfxObj meshes have large built-in radii (thousands of meters).
**Do not apply an artificial scale** — the dat-provided vertex positions
are already in the "right" units for the retail fog system to work
correctly against `FOGSTART ∈ [0, 400]`, `FOGEND ∈ [150, 2400]` from
keyframes.
If our current implementation is placing the sky at the wrong distance
(too close ⇒ almost no fog; too far ⇒ always 100% fog), check:
1. Are we reading `GfxObj` vertex positions raw (no scaling)?
2. Is our `uModel` matrix setting the sky at world origin (translation
= 0, rotation = sky-heading rotation around Z + sky-arc rotation
around Y, from FUN_005079e0's two-axis transform)?
3. Is `uCameraWorldPos` the ACTUAL player world position (not 0)?
### Should fog use per-pixel (table) instead of per-vertex?
No — retail uses vertex fog. Per-vertex fog is correct for the sky dome
because the dome's triangles are large and the distance varies smoothly
across them, so per-vertex interpolation gives identical results to
per-pixel at the cost of massively fewer ALU cycles. (Modern GLSL can do
per-pixel fog cheaply, so the visual result should be indistinguishable;
use whichever is cleaner in our shader.)
## Summary of the acdream code-change recommendation
1. **Keep fog enabled for the sky pass.** The sky draw goes through the
normal mesh path; fog contributes to the horizon color by design.
2. **Use linear fog**, compute `fogFactor` per-vertex with `clamp((FOGEND
- dist) / (FOGEND - FOGSTART), 0, 1)`, where `dist = length(world -
cameraWorld)` (3D distance, not eye-Z).
3. **Use the keyframe-lerped FOGSTART/FOGEND/FOGCOLOR** (from
SkyTimeOfDay.Min/Max/WorldFogColor, interpolated on LightTickSize
cadence). Already in `SkyStateProvider`.
4. **Draw sky meshes at world-origin** with a rotation-only transform.
Do NOT strip the camera's view translation — the camera's world
position is correct, and the sky's distance from the camera is the
mesh's intrinsic radius relative to the camera's world position. This
matches retail.
## Files cited
- `chunk_00500000.c:6213-6333``FUN_005062e0` (per-frame sky+fog tick)
- `chunk_00500000.c:7535-7603``FUN_00508010` (sky render loop)
- `chunk_00500000.c:7571-7586` — sky transform setup (trans=0, quat=id)
- `chunk_00530000.c:4509-4531``FUN_00535b30` (quat-to-3x3, no trans)
- `chunk_00510000.c:4563-4591``FUN_00514b90` (mesh draw enqueue)
- `chunk_005A0000.c:3361-3389` — device-init state block (FOGVERTEXMODE=3,
FOGTABLEMODE=0, FOGSTART=400, FOGEND=2000, RANGEFOGENABLE=1)
- `chunk_005A0000.c:2868-2907``FUN_005a4080` (per-frame fog writer:
FOGCOLOR/START/END only)
- `chunk_005A0000.c:2808-2819``FUN_005a3f90` (FOGENABLE master gate)
- `references/WorldBuilder/.../SkyboxRenderManager.cs:247` — independent
confirmation that AC sky GfxObj meshes are at "large distances" in dat
- `docs/research/2026-04-23-sky-decompile-hunt-B.md:300-349` — hunt B
confirming no per-frame FOGVERTEXMODE writes, no view-matrix strip,
no huge far-plane constants
- `docs/research/2026-04-23-sky-material-state.md:56-95` — hunt that
fog stays enabled through sky render
## Remaining uncertainty
- **Exact sky GfxObj mesh radius** is in the `.dat` file and was not
decompiled. For a faithful port, load the mesh and inspect its max
vertex magnitude; compare to typical FOGEND = 2400. WorldBuilder
evidence suggests 3000+ meters.
- `_DAT_007c6f14` — the weather-far-plane multiplier. Only used in the
weather-volume pass (`FUN_00507a50`), not sky. Likely a small (< 3)
constant.
- Billboard flag `(*(byte*)(param_1[6] + uVar7 * 4) & 4)` at
`chunk_00500000.c:7579` — when set, the sky object takes a 3-float
translation from `iVar5 + 0x84..0x8c`. Not addressed here; typical
sky objects (dome, stars, sun, moon) are likely NOT billboard-flagged
and render at origin.

View file

@ -0,0 +1,184 @@
# 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.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.

View file

@ -189,11 +189,35 @@ public sealed class WeatherSystem
{ {
if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear;
string lc = name.ToLowerInvariant(); string lc = name.ToLowerInvariant();
// Order matters — "thunderstorm" contains "storm", match first. // Retail DOES NOT spawn rain/snow/storm particles based on the
if (lc.Contains("storm")) return WeatherKind.Storm; // DayGroup's NAME. Parallel decompile research 2026-04-23
if (lc.Contains("snow")) return WeatherKind.Snow; // (docs/research/2026-04-23-sky-pes-wiring.md +
if (lc.Contains("rain")) return WeatherKind.Rain; // docs/research/2026-04-23-physicsscript.md) verified:
if (lc.Contains("cloud") //
// 1. FUN_00508010 (the sky render loop) never reads
// SkyObject.DefaultPesObjectId — the field is dead at
// render time.
// 2. The PhysicsScript runtime (FUN_0051bed0 → FUN_0051bfb0)
// has no callers from the sky-render tree.
// 3. r12 deepdive claim that retail spawns rain from a sky
// SkyObject's PES was not corroborated by the decompile.
//
// Weather particle emission in retail therefore belongs to a
// SEPARATE camera-attached subsystem, not yet located. Until we
// find and port that subsystem, we must NOT invent our own
// "Rainy DayGroup name → spawn rain particles" path — it produced
// the user-observed regression 2026-04-23 (acdream rained on a
// DayGroup that retail rendered without any rain particles).
//
// Therefore ALL weathery names map to Overcast — they get the
// correct keyframe-driven fog/cloud tone, without the particle
// emitter. Clear names stay Clear. No Rain / Snow / Storm is
// ever returned from name matching. Tests kept for Storm/Rain
// constants since ForceWeather still supports them for debug.
if (lc.Contains("storm")
|| lc.Contains("snow")
|| lc.Contains("rain")
|| lc.Contains("cloud")
|| lc.Contains("overcast") || lc.Contains("overcast")
|| lc.Contains("dark") || lc.Contains("dark")
|| lc.Contains("fog")) return WeatherKind.Overcast; || lc.Contains("fog")) return WeatherKind.Overcast;

View file

@ -101,21 +101,26 @@ public sealed class WeatherSystemTests
} }
[Theory] [Theory]
[InlineData("Sunny", WeatherKind.Clear)] [InlineData("Sunny", WeatherKind.Clear)]
[InlineData("SUNNY", WeatherKind.Clear)] [InlineData("SUNNY", WeatherKind.Clear)]
[InlineData("Clear", WeatherKind.Clear)] [InlineData("Clear", WeatherKind.Clear)]
[InlineData("Cloudy", WeatherKind.Overcast)] [InlineData("", WeatherKind.Clear)]
[InlineData("Overcast", WeatherKind.Overcast)] [InlineData(null, WeatherKind.Clear)]
[InlineData("Dark skies", WeatherKind.Overcast)] // All "weathery" names map to Overcast. Retail does NOT spawn rain /
[InlineData("Fog", WeatherKind.Overcast)] // snow / lightning from the DayGroup name — verified by the 2026-04-23
[InlineData("Rainy", WeatherKind.Rain)] // PhysicsScript + sky-PES decompile audits (see WeatherState.cs). Any
[InlineData("heavy rain", WeatherKind.Rain)] // future particle rain must come from the camera-attached weather
[InlineData("Snowy", WeatherKind.Snow)] // subsystem, NOT from name string matching.
[InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default [InlineData("Cloudy", WeatherKind.Overcast)]
[InlineData("Stormy", WeatherKind.Storm)] [InlineData("Overcast", WeatherKind.Overcast)]
[InlineData("Thunderstorm", WeatherKind.Storm)] // "storm" wins over no match [InlineData("Dark skies", WeatherKind.Overcast)]
[InlineData("", WeatherKind.Clear)] [InlineData("Fog", WeatherKind.Overcast)]
[InlineData(null, WeatherKind.Clear)] [InlineData("Rainy", WeatherKind.Overcast)]
[InlineData("heavy rain", WeatherKind.Overcast)]
[InlineData("Snowy", WeatherKind.Overcast)]
[InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default
[InlineData("Stormy", WeatherKind.Overcast)]
[InlineData("Thunderstorm", WeatherKind.Overcast)]
public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected) public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected)
{ {
var sys = new WeatherSystem(); var sys = new WeatherSystem();