acdream/docs/research/2026-04-23-lightning-crossfade.md
Erik 53608e77e3 sky(phase-5a): remove DayGroup-name rain hack, ship retail-only Overcast mapping
User-observed regression 2026-04-23: acdream spawned rain particles
when retail showed no rain at the same server tick. Root cause: my
Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain →
rain particle emitter. That's not what retail does.

Parallel decompile research confirms:
- Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives
  at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it
  from NOWHERE.
- Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render
  loop) never reads SkyObject.DefaultPesObjectId — the field is dead
  at render time. Rain/snow particles in retail come from a separate
  camera-attached weather subsystem that has NOT yet been located.

So the correct behavior is: DayGroup name should only drive
fog/ambient tone (via keyframes, already in the Snapshot path),
never spawn particle emitters. Any retail-faithful particle rain
belongs to a future phase once we find the camera-attached weather
subsystem driver.

Change: MapDayGroupNameToKind now maps all weathery substrings
(storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only
visuals, no particle spawn. Clear names stay Clear. The Rain, Snow,
Storm enum values remain and are still accessible via ForceWeather()
for debug overrides.

Tests updated (WeatherSystemTests): the name→kind theory now expects
Overcast for Rainy/Snowy/Stormy variants.

Also commits the four research docs from this session's parallel
hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding),
lightning timer (negative finding — agent #3), fog on sky
(positive: retail applies fog to sky geometry).

NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE
RANDOM TIMER hypothesis for lightning. User confirms retail does have
visible lightning + thunder. A follow-up agent (#5, in flight as of
this commit) is hunting the real mechanism — PlayScript opcode,
SetLight PhysicsScript hooks, AdminEnvirons side effects, or the
weather-volume draw. This commit does NOT attempt to port lightning.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:04:36 +02:00

438 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.