diff --git a/docs/research/2026-04-23-lightning-crossfade.md b/docs/research/2026-04-23-lightning-crossfade.md new file mode 100644 index 0000000..23eedd8 --- /dev/null +++ b/docs/research/2026-04-23-lightning-crossfade.md @@ -0,0 +1,438 @@ +# Lightning Flashes & Weather Crossfade — Decompile Research + +**Date:** 2026-04-23 +**Scope:** Answer Q1–Q5 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; + + /// FUN_0055eb40 — server AdminEnvirons(opcode 0xEA60) handler. + 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; } + + /// Called each time the LightTickSize gate fires (~every 2 s). + 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. diff --git a/docs/research/2026-04-23-physicsscript.md b/docs/research/2026-04-23-physicsscript.md new file mode 100644 index 0000000..b258ec7 --- /dev/null +++ b/docs/research/2026-04-23-physicsscript.md @@ -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 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 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` | software-render mesh (ignored by retail — always uses HW) | +| `HwGfxObjId` | `QualifiedDataId` | 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 ScriptTable; +// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values) +// PhysicsScriptTableData = List Scripts (weighted variants) +// ScriptAndModData = { float Mod; QualifiedDataId 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 + 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(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(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(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. diff --git a/docs/research/2026-04-23-sky-fog.md b/docs/research/2026-04-23-sky-fog.md new file mode 100644 index 0000000..d1fd234 --- /dev/null +++ b/docs/research/2026-04-23-sky-fog.md @@ -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. diff --git a/docs/research/2026-04-23-sky-pes-wiring.md b/docs/research/2026-04-23-sky-pes-wiring.md new file mode 100644 index 0000000..a422f55 --- /dev/null +++ b/docs/research/2026-04-23-sky-pes-wiring.md @@ -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 Q1–Q4 has the same answer: **retail doesn't do it here.** The PES-from-SkyObject pathway is dead code at the render stage — either disabled in retail, or the id is consumed by code outside `chunk_00500000.c` that isn't called from the sky path we traced. The r12 deepdive note at `deepdives/r12-weather-daynight.md:423-426` corroborates: *"Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` … **that attaches a particle emitter to the camera**."* The emitter lives on the camera, not on the sky entity, and the dat files for retail-shipped regions don't actually populate it on any sky object the audit has examined. + +Full evidence below. + +--- + +## Q1 — PES-start call site inside `FUN_00508010` + +**There is none.** Full loop body (`chunk_00500000.c:7567-7599`): + +```c +do { + if (*(int *)(param_1[3] + uVar7 * 4) != 0) { // slot has GfxObjId? + uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1); // +0x08 = Rotate override (NOT Pes) + uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc); // +0x0c = Arc angle + local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0; // identity quat + local_14 = 0; local_10 = 0; local_c = 0; // zero translation + FUN_00535b30(); // reset current xform + if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { // Properties bit 2 set? + iVar5 = *(int *)param_1[3]; + local_14 = *(undefined4 *)(iVar5 + 0x84); // custom translation X + local_10 = *(undefined4 *)(iVar5 + 0x88); // Y + local_c = *(undefined4 *)(iVar5 + 0x8c); // Z + } + FUN_005079e0(&local_48, uVar3, uVar4); // rotate (mesh-roll + arc) + FUN_00514b90(&local_48); // enqueue mesh draw + if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1)) + FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0); // Luminosity + if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1)) + FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0); // MaxBright + if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1)) + FUN_005120c0( *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0); // Transparent + } + uVar7 = uVar7 + 1; + iVar6 = iVar6 + 0x2c; +} while (uVar7 < uVar2); +``` + +**Offsets touched inside the loop:** `+0x08, +0x0c, +0x1c, +0x20, +0x24` and the Properties byte. **`+0x04` (the PesObjectId slot) is NEVER read** anywhere in this function or in `FUN_004ff4b0`/`FUN_00502a10`'s render-time code path. A grep confirms no occurrence of `iVar6 + 4 + *param_1` or `iVar6 + 0x04 + *param_1` in `chunk_00500000.c`. + +The previous audit (`2026-04-23-sky-decompile-hunt-A.md` §5.3) inferred `uVar3` was rotation-axis-1, but labeled its source as "unknown field at +8". That field is **the `Rotate` override from `SkyObjectReplace+0x0c`** — proven by `FUN_00502a10:2532-2534`: + +```c +fVar1 = *(float *)(*(int *)(local_34 + 0x2c) + local_38 * 4) + 0xc); // Replace.Rotate +if (fVar1 != DAT_00796344) { + *(float *)(uVar6 * 0x2c + 8 + *piVar5) = fVar1; // stored at per-frame+0x08 +} +``` + +So the `+0x08` slot is a **mesh-roll angle**, not a PhysicsScript pointer. + +--- + +## Q2 — PES lifecycle for visible SkyObjects + +**There is no lifecycle.** The sky draw path does not: +1. Allocate a PES instance per SkyObject +2. Hold a "currently-running PES" back-pointer anywhere in SkyObject, per-frame table entry, Region, SkyDesc, or DayGroup +3. Call `FUN_0051bed0` (the PhysicsScript launcher) anywhere in the sky-render tree (`FUN_005062e0`, `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, `FUN_00507e20`, `FUN_005079e0`, `FUN_00514b90`) + +Verified by: +``` +$ grep -n "FUN_0051bed0\|FUN_0051be40\|FUN_0051bfb0\|FUN_0051c040" chunk_00500000.c +(no results) +``` + +`FUN_0051bed0` (the PhysicsScript runner) is located in `chunk_00510000.c:11121`: +```c +undefined4 FUN_0051bed0(undefined4 param_1) { // param_1 = PhysicsScript dat ID + uVar1 = FUN_004220b0(param_1, 0x2b); // type 0x2b = PHYSICS_SCRIPT + iVar2 = FUN_00415430(uVar1); // dat-load + if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { // queue + return 1; + } + return 0; +} +``` + +Its only caller is `FUN_005117a0` (`chunk_00510000.c:1504`), which is the **PhysicsObject::RunScript** method: +```c +undefined4 FUN_005117a0(int param_1, int param_2) { // this=PhysicsObject, param_2=ScriptId + if (*(int *)(param_1 + 0x30) == 0) { // lazy-alloc ScriptManager at +0x30 + iVar1 = FUN_005df0f5(0x18); + if (iVar1 == 0) uVar2 = 0; + else uVar2 = FUN_0051be20(param_1); + *(undefined4 *)(param_1 + 0x30) = uVar2; + } + if (*(int *)(param_1 + 0x30) != 0) { + uVar3 = FUN_0051bed0(param_2); + } + return uVar3; +} +``` + +Every caller of `FUN_005117a0` is in PhysicsObject / weapon / combat code (`chunk_00510000.c:2432, 2470, 3719, 3741, 3771, 4190, 4855, 5231, 5261`). **None are in the sky renderer.** + +--- + +## Q3 — Day-change & DayGroup-change handling + +No such code. The SkyObject table rebuild in `FUN_00502a10` (triggered every frame via `FUN_004ff4b0`) does: +1. Grows/shrinks the output table size to match current DayGroup's `SkyObject.Count` (lines 2430-2480) +2. For each SkyObject, copies `GfxObjId/PesObjectId/Properties/Rotate/ArcAngle/TexVel` into the per-frame entry +3. Overlays the current SkyTimeOfDay's `SkyObjectReplace[]` entries + +**Nothing in this rebuild path allocates, cleans up, or references a PhysicsScript owner.** `FUN_00502a10` treats `PesObjectId` as an opaque dword — copy from `SkyObject+0x28` to per-frame entry `+0x04` (line 2492) — and that's the last time it's touched. + +The only "lifecycle" seen is the DayGroup variant roll (`FUN_00501990`), which re-rolls *which* DayGroup is active based on a deterministic hash of the player weenie's state. That affects which `SkyObject[]` gets iterated, but again — nothing in the DayGroup-change path touches PES. + +--- + +## Q4 — The particle-emitter parent + +Per the r12 deepdive `deepdives/r12-weather-daynight.md:423-426, 447-476`: + +> Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the sky object) **that attaches a particle emitter to the camera**. This emitter fires rain/snow particles regardless of the server. + +> Rain in AC is a `ParticleEmitter` **attached to the camera** at an offset of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing streak-style particles downward. + +So the **owner is the camera PhysicsObject**, not any SkyObject. When (if) retail does emit weather particles, it's via the camera's own `RunScript` invoked from a code path we haven't traced — likely a weather manager hooked to `EnvironChange` events, not to the sky-render loop. + +Given the `DefaultPesObjectId` isn't read during render, the most likely place it would be consumed is **region-load time** — when `FUN_004ff370` loads the Region and its SkyDesc, a weather manager could walk every SkyObject, find any non-zero PesObjectId, and use it to initialize a camera-attached emitter template. But no such code was found inside `chunk_00500000.c` or the Region loader path; it would live in a separate weather/particle subsystem (probably `chunk_00510000.c` or `chunk_005A0000.c`). + +--- + +## Q5 — Port-ready pseudocode + +Because retail does not run PES per sky object, the port pseudocode is the null program: + +``` +frame tick: + for each SkyObject in current DayGroup: + # exactly what FUN_00508010 does — draw the mesh, apply T/L/MB overrides. + # DefaultPesObjectId is copied into the per-frame table at +0x04 but never read. + visible_now = (BeginTime == EndTime) OR (BeginTime < t < EndTime) + if visible_now AND entry.GfxObjId != 0: + draw mesh with Rotate/ArcAngle rotations + apply Luminosity/MaxBright/Transparent overrides if > 0 + # NO PES START/STOP/UPDATE + +on DayGroup change: + # FUN_00501990 re-rolls active DayGroup index by deterministic hash. + # Does NOT touch any script state. + nop + +on Region unload: + # FUN_004ff3b0 releases Region via vtable[0x14]; no sky-specific PES cleanup. + nop +``` + +**What to do for acdream:** +- **Ship Phase 2 sky as geometry-only.** Do NOT add a SkyObject→ParticleEmitter spawn path based on `DefaultPesObjectId`. It would not match retail. +- **Retain `DefaultPesObjectId` in the parsed struct** (we already do — `SkyObject.DefaultPesObjectId` in `SkyState.cs`). It's data retail loads but doesn't use at render; keep it so future weather code can inspect it if we implement the camera-emitter path. +- **Weather particles are a SEPARATE feature.** If/when implemented, they belong in a `WeatherManager` that lives next to `WeatherState` enum + `EnvironChange` handling, attaches emitters to the camera entity, and is triggered by region-load + fog-keyframe transitions. That manager *may* scan each SkyObject's `DefaultPesObjectId` as one of its inputs, or it may use a hard-coded per-WeatherState table (rain.pes, snow.pes). Either approach is off the sky-render critical path. + +--- + +## Confidence + +- **High**: `FUN_00508010` does not call PES. Evidence: full line-by-line read; grep of entire `chunk_00500000.c` for any `FUN_0051bXX` / `FUN_0051cXX` — zero hits. +- **High**: `FUN_00502a10` copies PesObjectId through but doesn't act on it. Evidence: line 2492 writes `+0x04 = *(iVar4+0x28)`; nothing else in the function reads `+0x04`. +- **High**: `FUN_0051bed0` is the PhysicsScript launcher and is called only from `FUN_005117a0` (PhysicsObject::RunScript), never from sky code. +- **Medium**: Weather particles are camera-attached and sourced from a separate subsystem. Evidence: r12 deepdive assertion + absence of any sky-side PES spawn. The weather subsystem itself was not located in this hunt. +- **Unknown**: Whether any retail-shipped region dat (Dereth, dungeons) actually populates `DefaultPesObjectId` on any SkyObject. Worth a dat scan: open every Region's SkyDesc and tally non-zero PesObjectIds. If the answer is "zero across all regions", the field is effectively dead data in retail and our "do nothing" port is 100% correct. If some regions populate it, there's a weather subsystem somewhere that reads it — but not from the render path. + +--- + +## Pointers for future work + +- **Locate the weather manager.** Grep `chunk_005*` and `chunk_004*` for calls to `FUN_0051bed0` with a parameter sourced from a SkyDesc/SkyObject field. If it exists, it'll show up as a single call in a function that also touches `DAT_0084247c` (region global). +- **Scan retail dats for populated PesObjectIds.** `python tools/decompile_acclient.py` has no dat-scan helper, but the ACE `Region.cs` loader would parse every region — quick C# one-shot to tally non-zero Region.DayGroups[].SkyObjects[].DefaultPesObjectId values across all region IDs `0x13000000..0x1300FFFF`. +- **Confirm weather is independent of sky rendering** by verifying that acdream's rain/snow (if we ever implement them) can render with sky renderer disabled and vice-versa. This is the retail behavior per the r12 writeup. diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 5f421fd..e4c54f1 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -189,11 +189,35 @@ public sealed class WeatherSystem { if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; string lc = name.ToLowerInvariant(); - // Order matters — "thunderstorm" contains "storm", match first. - if (lc.Contains("storm")) return WeatherKind.Storm; - if (lc.Contains("snow")) return WeatherKind.Snow; - if (lc.Contains("rain")) return WeatherKind.Rain; - if (lc.Contains("cloud") + // Retail DOES NOT spawn rain/snow/storm particles based on the + // DayGroup's NAME. Parallel decompile research 2026-04-23 + // (docs/research/2026-04-23-sky-pes-wiring.md + + // docs/research/2026-04-23-physicsscript.md) verified: + // + // 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("dark") || lc.Contains("fog")) return WeatherKind.Overcast; diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index 9623f4f..f13a308 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -101,21 +101,26 @@ public sealed class WeatherSystemTests } [Theory] - [InlineData("Sunny", WeatherKind.Clear)] - [InlineData("SUNNY", WeatherKind.Clear)] - [InlineData("Clear", WeatherKind.Clear)] - [InlineData("Cloudy", WeatherKind.Overcast)] - [InlineData("Overcast", WeatherKind.Overcast)] - [InlineData("Dark skies", WeatherKind.Overcast)] - [InlineData("Fog", WeatherKind.Overcast)] - [InlineData("Rainy", WeatherKind.Rain)] - [InlineData("heavy rain", WeatherKind.Rain)] - [InlineData("Snowy", WeatherKind.Snow)] - [InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default - [InlineData("Stormy", WeatherKind.Storm)] - [InlineData("Thunderstorm", WeatherKind.Storm)] // "storm" wins over no match - [InlineData("", WeatherKind.Clear)] - [InlineData(null, WeatherKind.Clear)] + [InlineData("Sunny", WeatherKind.Clear)] + [InlineData("SUNNY", WeatherKind.Clear)] + [InlineData("Clear", WeatherKind.Clear)] + [InlineData("", WeatherKind.Clear)] + [InlineData(null, WeatherKind.Clear)] + // All "weathery" names map to Overcast. Retail does NOT spawn rain / + // snow / lightning from the DayGroup name — verified by the 2026-04-23 + // PhysicsScript + sky-PES decompile audits (see WeatherState.cs). Any + // future particle rain must come from the camera-attached weather + // subsystem, NOT from name string matching. + [InlineData("Cloudy", WeatherKind.Overcast)] + [InlineData("Overcast", WeatherKind.Overcast)] + [InlineData("Dark skies", WeatherKind.Overcast)] + [InlineData("Fog", WeatherKind.Overcast)] + [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) { var sys = new WeatherSystem();