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

19 KiB
Raw Blame History

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

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)

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

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):

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

// 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)

// 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

// 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-6333FUN_005062e0 per-frame sky+crossfade
  • docs/research/decompiled/chunk_00550000.c:11835-12016FUN_0055eb40 EnvironChangeType dispatcher
  • docs/research/decompiled/chunk_00550000.c:11906-11994 — thunder/ambient sound cases
  • docs/research/decompiled/chunk_006A0000.c:13141-13153FUN_006ae870 AdminEnvirons (0xEA60) network handler
  • docs/research/decompiled/chunk_00560000.c:2461-2467FUN_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-1136FUN_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.