acdream/docs/research/2026-04-23-lightning-real.md
Erik 1d54880213 sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:

* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
  (0..400m at midnight, up to 2400m during day) is calibrated for
  terrain; sky meshes are authored at radii 1050-14271m which sits
  past FogEnd universally, causing every sky pixel to saturate to
  fogColor (dark navy). Stars, moon, dome texture all got
  obliterated. The horizon-glow trade-off is noted in the shader
  comment; research item to find retail's sky-specific fog range
  later.

* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
  vertex lighting saturates properly for bright keyframes. Retail's
  FUN_0059da60 non-luminous path writes rep.Luminosity into
  material.Emissive via the cache +0x3c slot; we were instead using
  it as a post-fragment multiply which could only dim, never brighten.
  Net effect: daytime clouds now render saturated white, dome dims
  correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
  and moon unchanged.

* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
  (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
  pure ambient rather than getting an 8% sun floor.

New research / tooling (no runtime impact):

* docs/research/2026-04-24-lambert-brightness-split.md — retail's
  ambient-brightness formula pinned from PE .rdata read + live
  RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
  where scale constant 0x0079a1e8 = 0.2f exactly.

* docs/research/2026-04-23-lightning-real.md — research note on the
  dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
  explicit PES-triggered flash SkyObjects with 5ms time windows).

* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
  DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
  backwards).

* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
  sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
  and the 0x0079a1e8 scale-factor readout.

* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
  deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
  (A8R8G8B8 128x128 texture, 4% bright-pixel ratio).

* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
  histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
  clouds decoded with proper alpha" type questions.

README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.

All 742 tests green.
2026-04-24 20:34:36 +02:00

16 KiB
Raw Permalink Blame History

Lightning (the real mechanism) — Decompile Research

Date: 2026-04-23 Scope: User confirms retail AC shows visible lightning flashes paired with thunder audio during storms. Prior research (2026-04-23-lightning-crossfade.md Q1) ruled out a client-side timer flash. This hunt chases H1H5 for the real trigger. Outcome: Found the PlayScript (0xF754) dispatcher; ruled out all five in-decompile hypotheses as a built-in lightning flash mechanism; propose the most likely remaining explanation (server-side PhysicsScript on a "weather effect" object, with the visual in the PES hooks). Port-ready pseudocode for the PlayScript wire path is included.


TL;DR

Retail's client has no dedicated lightning subsystem. The only general "make a visual thing happen from a server message" channel is opcode 0xF754 = PlayScriptId (chunk_006A0000.c:12320-12336), which dispatches a server-supplied PhysicsScript (0x33xxxxxx) onto any object by GUID via FUN_00452060 → FUN_00511800 → FUN_005117a0 (PhysicsObj.play_script) → ScriptManager (analyzed in 2026-04-23-physicsscript.md). The PhysicsScript then runs CreateParticleHook / SetLightHook / Sound hooks at scheduled times.

All in-client paths that could "spontaneously" flash — the storm preset 6 flag, SetLightHook, AdminEnvirons Thunder subtypes 0x650x6A, the weather-volume draw FUN_00507a50, any RNG tied to the sky — are falsified or ruled inapplicable. The lightning flash a user sees in retail is either:

  • (most likely) a PhysicsScript the server broadcasts via 0xF754 at pseudo-random intervals during storm weather, attached to an off-screen "storm cloud" object or the player, with the visual implemented as a CreateParticleHook on a very bright short-lived emitter + a SoundHook for the thunder, OR
  • (possible) a server-side system the decompile reveals no trace of in the client — e.g. ACE-style (but richer than current ACE) AdminEnvirons extensions, OR a modern-port addition layered on top of retail.

ACE's 2.x branch (the vendored reference) does not broadcast any lightning-like PlayScript or periodic AdminEnvirons Thunder; its EnvironChangeType enum only covers the same 7 fog presets + 6 thunder sounds the client knows. So either retail's server had logic ACE never ported, or the user is running a server-side mod/expansion that sends lightning packets.


H1: Server-broadcast PlayScript (0xF754) — CONFIRMED channel, unknown content

The dispatcher

chunk_006A0000.c:12320-12336:

undefined4 FUN_006adba0(int param_1, int param_2)
{
  int *piVar1;
  undefined4 uVar2;
  if ((param_2 != 0) && (param_1 != 0)) {
    piVar1 = *(int **)(param_2 + 0x2c);    // packet payload ptr
    if (*piVar1 == 0xf754) {                // opcode match
      uVar2 = FUN_00452060(param_2, piVar1[1], piVar1[2]);
      return uVar2;
    }
  }
  return 3;
}

The bridge to PhysicsScript

chunk_00450000.c:1043-1057:

int FUN_00452060(undefined4 param_1, undefined4 param_2, undefined4 param_3)
{
  int iVar1;
  iVar1 = FUN_00508890(param_2);               // find PhysicsObj by guid (hash lookup)
  if (iVar1 == 0) {
    FUN_00509da0(param_2, param_1);            // queue for later (object not loaded yet)
    return 4;
  }
  iVar1 = FUN_00511800(param_3);               // play_script(scriptId) on it
  return (-(uint)(iVar1 != 0) & 0xfffffffe) + 3;
}

PlayScript entry into the PhysicsScript runtime

chunk_00510000.c:1535-1547:

undefined4 __fastcall FUN_00511800(int param_1)
{
  undefined4 uVar1;
  if (*(int *)(param_1 + 0x90) == 0) {
    return 1;
  }
  uVar1 = FUN_005117a0();                      // = PhysicsObj.play_script_internal
  return uVar1;
}

From here, FUN_005117a0 lazily instantiates a ScriptManager at PhysicsObj+0x30 and calls FUN_0051bed0(scriptID) — exactly the path documented in 2026-04-23-physicsscript.md. So the PlayScript opcode executes an arbitrary PhysicsScript on any PhysicsObj the server addresses by GUID.

Wire format

[u32 opcode = 0xF754][u32 objectGuid][u32 scriptId]

Payload size: 12 bytes. No speed multiplier. (Contrast ACE's GameMessageScript: guid + scriptId + speed(float) = 16 bytes. ACE's client impl would need to add this; retail's client handles only the no-speed form here — ACE may have a slightly different handler or the speed modifier lives at piVar1[3] if the packet is larger.)

What this means for lightning

This IS the channel. If retail shows lightning, the most parsimonious explanation is: the server (original Turbine server, not necessarily ACE 2.x) sends PlayScript(guid, scriptId=<flash_script_id>) at pseudo-random intervals during storm weather. The script ID is a 0x33xxxxxx PhysicsScript that contains, minimally:

  • One or more CreateParticleHook entries with EmitterInfoId pointing to a ParticleEmitter configured for a very bright, short-lived, camera-parented flash mesh (white billboard, additive blend, high luminosity, ~0.050.3s lifespan).
  • One or two SoundHook entries with StartTime offset by 15 seconds (light-then-thunder) referencing Thunder16 sound IDs 0x76..0x7B.
  • Optionally a second CreateParticleHook for lightning-bolt geometry, or a Diffuse/Luminous hook for a brief self-illumination of nearby objects.

The flash "renders" as a particle billboard through the normal PhysicsScript → ParticleEmitter pipeline (documented in ACE's ParticleEmitter.cs). No scene-wide ambient write, no D3DLIGHT modulation, no framebuffer tint — just a bright additive sprite drawn by the existing particle pipeline.

Thunder is same-script-different-hook: SoundHook entries in the same PhysicsScript.ScriptData list, with StartTime offset to produce the visible-then-audible delay. Alternatively, they could be separate AdminEnvirons(0x65..0x6A) messages the server sends timed after the PlayScript — but a single PhysicsScript with both CreateParticle and Sound hooks is cheaper and more natural.

Gap: the actual scriptId(s) used

Neither the decompiled client code nor ACE 2.x nor the other references contains a known "lightning flash" PhysicsScript ID. The id space is 0x33000000..0x3300FFFF; the PlayScript enum (client-friendly aliases) uses IDs 0x00..0xAD but none are labeled Lightning/Flash/Strike/Storm-Flash. The only weather-adjacent alias is PortalStorm = 0x73 (portal-restriction effect), and BreatheLightning = 0x57 (a creature ability).

So: the script ID is either in the dat files (to be discovered by dumping all 0x33xxxxxx PhysicsScripts and looking for ones whose hook pattern matches "short bright flash + thunder sound"), or it's a DefaultPesObjectId on a weather-related scene object the user's server spawns during storms.

Recommendation for acdream: if the visual confirmation says "yes, retail flashes", run the existing ACDREAM_DUMP_MOTION=1 equivalent (we'd need a new ACDREAM_DUMP_PLAYSCRIPT=1) to log every 0xF754 packet during a storm. The script IDs will be in the dump.


H2: SetLightHook is NOT world-flash — RULED OUT

Schema (DatReaderWriter/.../SetLightHook.generated.cs:23-27):

public partial class SetLightHook : AnimationHook {
    public override AnimationHookType HookType => AnimationHookType.SetLight;
    public bool LightsOn;
    ...
}

Payload is a single bool. This toggles one lamp on one PhysicsObj's part (used by tavern lanterns, torch creatures, skeletal-warrior eyes, etc.). It is not a scene-wide brightness override, so even a timed sequence of SetLightHook true → false → true can't produce a global flash. Falsified.


H3: AdminEnvirons Thunder cases do NOT also flash — RULED OUT

chunk_00550000.c:11906-11994 dispatches subtypes 0x65..0x72 and 0x75..0x7B to FUN_00551560(soundId, channelObj) — the play-sound-now call — with no visual side effect:

case 0x65:
  uVar1 = FUN_00564d50();              // get/alloc sound channel
  FUN_00551560(0x76, uVar1);           // play Thunder1Sound
  return 0;
case 0x66:
  uVar1 = FUN_00564d50();
  FUN_00551560(0x77, uVar1);           // play Thunder2Sound
  return 0;
/* ... through 0x6A ... */

Each case returns 0 without touching the fog/ambient/weather globals, the D3D state, or any particle system. Falsified.


H4: FUN_00507a50 weather-volume pass does NOT render a flash — RULED OUT

chunk_00500000.c:7250-7299. Only D3D state changes are:

  • FUN_005a3f90(DAT_008427a9 != '\0') — FOGENABLE ← weather flag
  • FUN_005a3e20(8, 0) — ZFUNC=ALWAYS, ZWRITE=0
  • FUN_0054bf30(...) — far-plane multiplier

Then it iterates weather volume objects and calls generic scene-graph update+draw (FUN_00511720 + FUN_00511760). Any flash would have to come from one of those volumes' own PhysicsScript — which brings us back to H1. No standalone flash logic in this function.

Falsified as a new mechanism.


H5: ACE has no lightning — CONFIRMED, notable

references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs

Only 7 fog variants + 6 thunder sounds + a couple miscellaneous sounds. No "Lightning" / "Strike" / "Flash" member. ACE's LandblockManager and Landblock.cs do call SendEnvironChange / SendEnvironSound for fog and sound, but:

  • grep Thunder|Lightning across ACE's Server/**.cs turned up only item names, spell IDs, character-title strings, and the 6 thunder sound enum values. Zero server code periodically broadcasts a thunder sound or a lightning PlayScript.
  • ACE has GameMessageScript (opcode 0xF755 PlayEffect) used for spell effects, level-ups, portals, creature deaths, etc. Also 0xF754 PlayScriptId is declared but not used by any of the 48 call sites I found (which all go through GameMessageScript + PlayEffect = 0xF755).
  • The PlayScript enum has no Lightning/StormFlash/Strike entries.

Implication: the server ACE vendors (2.x line) does not emit lightning. Therefore one of the following is true:

  1. The user's running server is an older/modded ACE or a different emulator that does send lightning packets.
  2. The retail production server had logic ACE never ported — specifically a per-landblock storm tick that sent 0xF754 PlayScript with a lightning PhysicsScript at randomized intervals.
  3. The user saw lightning in a different client/era (retail 2005-era vs 2017-era vs a private shard mod) that doesn't correspond to what ACE 2.x does today.

Either way: the retail CLIENT will respond to 0xF754 by running whatever PhysicsScript the server names. So acdream's job is to port that pathway and let the server drive it — same as with spell effects, death animations, portal travel, etc.


Port-ready C# pseudocode

Wire the PlayScript opcode (0xF754)

// src/AcDream.Core/Events/GameEventDispatcher.cs
// Retail opcode 0xF754 = PlayScriptId.
// Wire: [u32 opcode][u32 targetObjectGuid][u32 scriptId]
// Routes into the PhysicsScript runtime documented in 2026-04-23-physicsscript.md.
public void OnPlayScriptId(BinaryReader r)
{
    uint guid     = r.ReadUInt32();
    uint scriptId = r.ReadUInt32();

    // Decompile FUN_00452060 (chunk_00450000.c:1043-1057):
    var obj = _world.FindPhysicsObjectByGuid(guid);
    if (obj == null)
    {
        _pendingPlayScripts.Enqueue((guid, scriptId));   // FUN_00509da0 queue
        return;
    }
    obj.PlayScript(scriptId, modifier: 1f);
}

// Also handle 0xF755 PlayEffect (ACE's preferred opcode — adds speed multiplier)
// Wire: [u32 opcode][u32 guid][u32 scriptId][f32 speed]
public void OnPlayEffect(BinaryReader r)
{
    uint guid     = r.ReadUInt32();
    uint scriptId = r.ReadUInt32();
    float speed   = r.ReadSingle();
    var obj = _world.FindPhysicsObjectByGuid(guid);
    if (obj == null) { _pendingPlayScripts.Enqueue((guid, scriptId)); return; }
    obj.PlayScript(scriptId, modifier: speed);
}

Flush pending on object arrival (port of FUN_00509da0)

// When a new PhysicsObject arrives (CreateObject / streaming visibility):
private void OnPhysicsObjectCreated(PhysicsObject obj)
{
    // drain pending queue for this GUID
    var drained = new List<(uint g, uint s)>();
    while (_pendingPlayScripts.TryDequeue(out var item))
    {
        if (item.g == obj.Guid) obj.PlayScript(item.s, 1f);
        else drained.Add(item);
    }
    foreach (var d in drained) _pendingPlayScripts.Enqueue(d);
}

Rely on the existing PhysicsScriptRuntime port for rendering

Once 0xF754 wires, acdream's existing PhysicsScriptRuntime.cs (the port sketched in 2026-04-23-physicsscript.md §5) handles everything else: ScriptManager.Start(scriptId) → Tick(now) → ExecuteHook → ParticleSystem.SpawnEmitter. The "lightning flash" visual is whatever the server-supplied PhysicsScript's hooks say it is — no special-cased code needed.

Optional: runtime discovery

Add diagnostic env var ACDREAM_DUMP_PLAYSCRIPT=1 that logs every 0xF754 / 0xF755 packet with guid, scriptId, and timestamp. Then during a thunderstorm the user can post-hoc filter the log for candidate "lightning" scriptIds, dump their PhysicsScript hook tables via DatCollection, and confirm the flash is a CreateParticleHook on a bright additive emitter.


Citations

  • docs/research/decompiled/chunk_006A0000.c:12320-12336FUN_006adba0 opcode 0xF754 dispatcher
  • docs/research/decompiled/chunk_00450000.c:1043-1057FUN_00452060 GUID-lookup + play_script bridge
  • docs/research/decompiled/chunk_00510000.c:1535-1547FUN_00511800 play_script-by-id wrapper
  • docs/research/decompiled/chunk_00510000.c:1504-1531FUN_005117a0 PhysicsObj.play_script (lazy ScriptManager)
  • docs/research/decompiled/chunk_00510000.c:11119-11216 — ScriptManager runtime (from prior research)
  • docs/research/decompiled/chunk_00550000.c:11906-11994 — AdminEnvirons Thunder subtypes (sound-only)
  • docs/research/decompiled/chunk_00500000.c:7250-7299FUN_00507a50 weather-volume pass (no flash)
  • docs/research/decompiled/chunk_004D0000.c:3888-3919 — storm flag (+0x41) IS read, but only to suppress the overhead-name/radar label pass during storms (not a lightning hook)
  • references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs:1-48 — no Lightning enum value
  • references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs:63-64PlayScriptId = 0xF754, PlayEffect = 0xF755
  • references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs:1-16 — ACE's builder (uses 0xF755 PlayEffect)
  • references/ACE/Source/ACE.Entity/Enum/PlayScript.cs:1-180 — full retail PlayScript alias table (no lightning member)
  • references/DatReaderWriter/DatReaderWriter/Generated/Types/SetLightHook.generated.cs:22-44 — SetLightHook = bool LightsOn only

Gap / What to try next

  1. Capture a live 0xF754 trace during a storm. Add a diagnostic dump of inbound PlayScript packets to acdream's session layer. Run the client while the test server (ACE-based or user's modded shard) has lightning active. Filter for script IDs correlated with the visible flash.
  2. If no 0xF754 traffic arrives, the user's lightning is NOT server-driven. Two remaining avenues:
    • DefaultPesObjectId on an EnvCell / scene object. The sky research hinted at DefaultPesObjectId = 0x330007DB on DayGroup[0] SkyObject[6] — but that field isn't walked by retail's sky render loop (2026-04-23-physicsscript.md §4). Same might be true for landblock decorations: a scenery weenie with DefaultPesObjectId pointing to a flash script could be spawning a cloud that periodically flashes. Dump 0x33xxxxxx scripts whose name or embedded hook IDs contain "light"/"strike"/"flash".
    • Retail may have had lightning only in DirectX 8/9 builds not in the decompile chunk we have. The current 688K-line decompile is from acclient.exe build ~2005-era; later retail patches could have added/removed weather features. Compare to a different build if one is available.
  3. Compare the decompile chunk boundary chunk_00500000..00580000 — our research has mostly covered 00500000 (sky), 00510000 (physics), 00550000 (weather), 00560000 (weather mgr). There's still a lot of 00570000 and 00580000 unexamined. A focused search for "Lightning" constant strings, or for any function that writes to _DAT_008682bc/c0/c4 (the scene ambient globals) on a short timer, might surface a dedicated mechanism.

Word count: ~2,050.