Compare commits

...

10 commits

Author SHA1 Message Date
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
Erik
889b235886 weather(phase-7): gut WeatherSystem.Snapshot — passthrough keyframe fog
Final pre-decompile-era invention cleanup. Snapshot() now returns
the keyframe's fog (color, start, end) directly in all cases.
AdminEnvirons override replaces fog COLOR only; distances stay at
the keyframe's MinWorldFog/MaxWorldFog.

Removed:
  - FogForKind(kind, kf): the per-WeatherKind fog table with
    invented constants (Overcast 40-150m grey, Storm 25-90m dark,
    Rain 40-150m blue, Snow 60-200m white). Retail has no such
    logic — Agent #3's decompile scan found zero per-Kind fog
    manipulation in chunk_005* / chunk_006*. The SkyTimeOfDay
    keyframe interp (FUN_00501860) does all fog value selection.
  - OvercastFogStart/End, StormFogStart/End constants.
  - Storm-kind random lightning timer + _strikeJitter. Retail's
    lightning is server-driven via PlayScript (Phase 6), not a
    client timer — Agents #3 + #5 both rule this out.
  - Per-Kind cross-fade (_transitionT and TransitionSeconds-based
    lerp). Retail has a different crossfade — SkyTimeOfDay step
    blending via LightTickSize gating (_DAT_008427b8 + _DAT_007c7208)
    — which is the deferred Phase 5c "polish" item.

Result:
  - Clear: keyframe fog passthrough — unchanged behaviour.
  - Overcast / Rain / Snow / Storm: now ALSO keyframe passthrough.
    Previously these clobbered the keyframe with the invented
    constants, producing a grey-wall sky that extended no further
    than ~150m. User observation 2026-04-23: "retail sky extends
    all the way into the horizon, we cap at a grey wall." Fixed.
  - EnvironOverride (AdminEnvirons RedFog, BlueFog, etc):
    substitutes the fog COLOR preset, keeps keyframe distances.

WeatherKind enum retained as purely informational (debug overlay,
telemetry). Internal RollKind fallback retained for offline tests
that drive Tick() directly without SetKindFromDayGroupName.
TriggerFlash()/flash decay retained as a test-only hook for the
UBO's lightning-flash channel — production flash stays 0 since
retail drives lightning visuals through particle emitters, not
through a UBO uniform.

Tests updated: `Transition_EasesAcrossTenSeconds` deleted (codified
the Storm=dense-fog invention we just removed) and replaced by
`Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind` which asserts
every WeatherKind returns the keyframe fog directly.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:19 +02:00
Erik
e4cf3a9b6b weather(phase-5d): AdminEnvirons packet handler + thunder sound dispatch
Ports retail's AdminEnvirons (opcode 0xEA60) — the client-visible
weather-event channel distinct from the PlayScript path. Wire format
(chunk_006A0000.c: `[u32 opcode][u32 environChangeType]`).

EnvironChangeType range:
  0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/Black/Black2)
  0x65..0x75 — one-shot ambient sounds (Roar, Bell, Chant, etc)
  0x76..0x7B — Thunder1..6 sounds (paired with a lightning PlayScript)

Dispatch:
  - WorldSession decodes the packet, fires EnvironChanged event.
  - GameWindow.OnEnvironChanged:
    * Fog values (0x00..0x06) → WeatherSystem.Override. The enum
      values line up byte-for-byte with our EnvironOverride enum
      (deliberately mirrored from retail), so a direct cast works.
    * Sound values (0x65..0x7B) → console log with retail name for
      now. Actual OpenAL playback needs a EnvironChangeType →
      WaveData lookup (indexed via SoundTable dat), which is a
      separate follow-up. The event still fires so any future
      audio subscriber can plug in.

Combined with Phase 6a-6c PhysicsScript/PlayScript wiring, the
complete retail lightning pipeline is now:

  server sends PlayScript(0xF754, lightningGuid, scriptId=0x33xxxxxx)
    → runs the flash script via PhysicsScriptRunner
    → CreateParticleHook spawns the flash particles
  server sends AdminEnvirons(0xEA60, Thunder3Sound=0x78)
    → OnEnvironChanged logs; audio binding TBD

Whether the user's ACE sends these packets depends on the server
(ACE 2.x vanilla does NOT — Agent #5 verified no lightning opcodes in
the default emit path). With the client port complete, any ACE mod
or extension that emits the right packets will Just Work in acdream.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:13 +02:00
Erik
2e9a836f08 weather(phase-6bc): wire PlayScript packet + script runner into frame loop
Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754)
that retail uses for all server-triggered client-side visual effects.
Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336):

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

New event `PlayScriptReceived(uint guid, uint scriptId)` fires on
every matching fragment. Unknown payloads (body < 12 bytes) are
silently ignored.

Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup,
subscribes to PlayScriptReceived, and ticks the runner every frame
BEFORE the ParticleSystem tick so a CreateParticleHook fired this
frame gets its emitter integrated in the same frame.

Anchor policy: use the camera's world position for the script anchor.
For Dereth-wide storm effects (lightning flashes) the camera is the
right reference frame — the flash is "around the player." Per-entity
effects (spell casts, emotes) dedupe by (scriptId, entityId) so
multiple simultaneous plays on different guids work; a follow-up will
look up the guid's last-known world pos from _worldState for accurate
per-entity anchoring.

The full pipeline now for a lightning flash:
  1. ACE (or other retail-emulating server) sends
     GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx).
  2. WorldSession parses: PlayScriptReceived event fires.
  3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play.
  4. Runner loads the PhysicsScript from the dat, schedules every
     (StartTime, AnimationHook) entry.
  5. Per-frame Tick fires each hook at its scheduled time via
     ParticleHookSink — CreateParticleHook spawns a particle emitter
     (the flash), SoundHook plays thunder audio (Phase 5d), etc.

Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each
hook fire as `[pes]` log lines — useful for identifying which script
IDs your ACE server actually sends.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:24:30 +02:00
Erik
845d70248c weather(phase-6a): port retail PhysicsScript runtime
The central runtime for every client-visible scripted effect server
triggers via PlayScript (opcode 0xF754) — spell casts, emote
gestures, combat flinches, AND lightning flashes during storms.
Previously acdream parsed PhysicsScript from the dat (via DRW) but
had no runner; the PlayScript stub in ParticleSystem.cs was a
no-op.

Decompile provenance (`docs/research/2026-04-23-physicsscript.md`,
`docs/research/2026-04-23-lightning-real.md`):

  FUN_0051bed0 — play_script(scriptId) public API — resolves the
                 dat id, queues into the owner's ScriptManager list.
  FUN_0051be40 — ScriptManager::Start — alloc 16-byte node
                 {startTime, script*, next}.
  FUN_0051bf20 — advance one hook, schedule next fire by next
                 hook's StartTime.
  FUN_0051bfb0 — per-frame tick: while head.NextHookAbsTime ≤
                 globalClock, fire via vtable dispatch.

Port choices:
  - Flat List<ActiveScript> vs retail linked list — iteration is
    simpler, N is small.
  - Scripts keyed by (scriptId, entityId) — replay replaces instead
    of stacking, matches retail's "play_script on the same obj
    doesn't double-schedule".
  - Anchor world pos cached at Play() time — good enough for
    short-lived effects (lightning, spell casts). Callers that
    need fresh positions for long emote animations can Play()
    again each frame (idempotent).
  - Constructor takes Func<uint, PhysicsScript?> resolver so tests
    don't need DatCollection; production uses the DatCollection
    overload that wraps Get<PhysicsScript> with null-on-fail.
  - CallPESHook recurses Play() with Pause baked into the
    sub-script's StartTimeAbs. Matches retail semantics where
    nested scripts fire on the NEXT tick (list iteration order).

Diag: ACDREAM_DUMP_PLAYSCRIPT=1 logs every Play() and every fire as
[pes] lines. Use this to identify the actual script IDs your ACE
server is sending so we can confirm the lightning pipeline when the
server sends a strike.

Test coverage (9 new tests, all passing):
  - unknown script returns false, zero id silent-ignore
  - hooks fire in order at their scheduled times
  - entityId + anchor pass through to sink
  - replay same (scriptId, entityId) replaces, doesn't stack
  - different entities run independently
  - StopAllForEntity cancels that entity's scripts only
  - CallPES nested spawn semantics (fires next tick)
  - CallPES with Pause delays correctly

No GameWindow wiring yet — Phase 6b handles the 0xF754 packet
handler and Phase 6c plugs the runner into the frame loop.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:20:39 +02:00
Erik
8a42750459 sky(phase-5b): port retail vertex fog onto sky meshes
Retail applies linear vertex fog with 3D range distance
(D3DRS_FOGVERTEXMODE=3=LINEAR, D3DRS_RANGEFOGENABLE=1,
D3DRS_FOGTABLEMODE=0=NONE) to ALL mesh draws including sky. Only
FOGCOLOR / FOGSTART / FOGEND are lerped per keyframe; the mode flags
are init-only.

Verified in `docs/research/2026-04-23-sky-fog.md`:
  - chunk_005A0000.c:3361-3389 device-init sets the modes.
  - Sky meshes render at world origin (translation zeroed, rotation-
    only) with intrinsic mesh radii in the thousands of meters
    (WorldBuilder's SkyboxRenderManager.cs:247 comment confirms).
  - With keyframe MaxWorldFog = 2400m, the dome saturates to
    WorldFogColor at its horizon band. THAT is retail's dusk/dawn
    horizon-glow mechanism.

Port:

`sky.vert` now computes the vertex fog factor:
    worldPos = uModel × aPos         (camera-centered since view translation=0)
    dist = length(worldPos.xyz)
    fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
  — outputs as varying vFogFactor. 1.0 means no fog contribution,
  0.0 means full fog color.

`sky.frag` applies the mix BEFORE the lightning-flash bump:
    rgb = mix(uFogColor.rgb, rgb, vFogFactor)

Uses the existing SceneLighting UBO's uFogParams (x=start, y=end,
z=flash, w=mode) and uFogColor — no new uniforms, no C# change.

Expected visual:
  - Dome at dawn/dusk: horizon band blends toward keyframe fogColor
    (warm orange at sunset, cool blue at dawn), matching retail's
    sky/fog coupling.
  - Close sky objects (sun disk at typical mesh radius): unaffected
    since dist < fogStart.
  - Clouds at intermediate distance: partial fog blend, subtly
    muting their saturation with distance.

Note on lightning: the flash channel (uFogParams.z) stays wired but
is currently always 0 because no code drives it. Agent #5 is
researching retail's real lightning mechanism (PlayScript / SetLight
PhysicsScript / other). This commit does not attempt to port it.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:06:57 +02:00
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
Erik
d5e37694ed docs(sky): port plan for PhysicsScript/fog/lightning/crossfade
Captures where we stand after Phase 4b and lays out the remaining
retail-faithful port work across four phases (5-8):

- Phase 5: PhysicsScript loader + runtime + sky lifecycle. Replaces
  WeatherSystem's crude "DayGroup name contains Rainy → spawn rain"
  shortcut with retail's actual PES-driven particle emission.
- Phase 6: Fog on sky meshes. The sky frag currently ignores fog
  uniforms; retail's D3D fog applies to sky.
- Phase 7: Lightning flash trigger + thunder audio for storm keyframes.
- Phase 8: Weather / DayGroup crossfade (DAT_008427a9 / _DAT_008427b8
  lerp) + AdminEnvirons override → fog crossfade.

User observation 2026-04-23 during Phase 4b verification: "Now it is
raining when it should not be." Root cause traced to the
SetKindFromDayGroupName string match firing rain particles on a "Rainy"
DayGroup regardless of whether that DayGroup actually has a visible
rain-emitting SkyObject. Proper fix requires porting PhysicsScript.

Also commits the earlier research from agent Q1-Q6:
`docs/research/2026-04-23-sky-material-state.md`.

Four parallel decompile agents are in flight as of this commit:
- PhysicsScript dat + runtime
- Sky↔PES wiring + emitter lifecycle
- Lightning + weather crossfade
- Fog on sky + vertex distance

Phase 5 implementation starts once those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:46 +02:00
Erik
2802fb2151 sky(phase-4b): clamp sky vTint at vertex + 1.0 fragment cap for retail parity
After Phase 4 landed the per-vertex lighting formula, user observed
acdream was still "a bit too bright" vs retail. Root cause:

- My Phase 4 shader deliberately left vTint unclamped so D3D-style
  overbright contributions to emissive meshes (dome has Emissive=1 → lit
  could reach 2.0 with ambient + sun) would clamp naturally at the
  framebuffer.
- But the frag cap was 1.2 (leaving "headroom for lightning flash"),
  letting dome vertices run 20% hotter than retail's per-channel 1.0.

Retail's D3D fixed-function pipeline clamps vertex lit colour at
D3DRS_COLORCLAMP=1 (default) BEFORE texture modulation. We now match:
- Clamp `vTint = clamp(lit, 0, 1)` in sky.vert so the saturate happens
  at the vertex stage, exactly like D3D.
- Drop normal-frame frag cap from 1.2 → 1.0 (the 3.0 flash relaxation
  stays so lightning strobes still visibly blow out).

Expected visual:
- Dome: identical appearance (was clamping to framebuffer 1.0 anyway),
  but pure retail-spec rendering so no sneaky 20% headroom.
- Clouds: unchanged (already < 1.0 at morning Rainy keyframe).
- Fragment flash during storm: unchanged — cap relaxes to 3.0 on flash.

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:41:58 +02:00
Erik
3a117bd91a sky(phase-4): retail-verbatim per-vertex lighting on sky meshes
Re-enables the Phase 2 lighting formula that was reverted in Phase 3b
due to a "blue-green-yellow sweep" across clouds. Root cause of that
earlier regression was NOT the formula — it was that we rolled the
wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm
sun against a sky that should have been rendered with diffuse
overcast light. After Phase 3g pinned the LCG multiplier to 360
(DaysPerYear) so retail + acdream agree on DayGroup, the same
per-vertex formula now faithfully reproduces retail's visuals.

The formula is verified in decompile agent Q2+Q4+Q6 results,
`docs/research/2026-04-23-sky-material-state.md`:

  D3DRS_LIGHTING = ON         (FUN_0059da60:10648)
  D3DRS_AMBIENT  = 0          (never written after init)
  Material.Emissive = (Luminosity, Luminosity, Luminosity, 1)
  Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous
  light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight)
  light.Diffuse = keyframe DirColor × DirBright

Fixed-function lighting per vertex:
  lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0)
      = Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0)

Fragment: texture × lit × SkyObjectReplace.Luminosity.

Expected visual:
- Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1
  → texture passthrough, baked gradient preserved.
- Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L`
  → purple haze at night (ambient dominates, sun below horizon);
  → warm tan at dusk (ambient + warm sun on west-facing vertices);
  → pale cool gray at noon (ambient + white sun from above).
- Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome +
  additive blend — stays bright regardless.

The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive)
were already wired in the C# renderer from Phase 2; Phase 3b just
stopped using them in the shader. This commit re-activates them.

No clamp at the vertex — retail's D3D lighting allows Emissive+sum
to exceed 1, relies on the framebuffer per-channel saturation. We
keep the 1.2 ceiling in the frag (for lightning flash overbright
headroom) consistent with that convention.

No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add
in a follow-up if horizon looks too bright).

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:37:40 +02:00
25 changed files with 4198 additions and 197 deletions

169
README.md
View file

@ -1,22 +1,173 @@
# acdream
Experimental modern open-source Asheron's Call client in C# / .NET 10.
A modern open-source C# / .NET 10 Asheron's Call client.
**Status:** pre-alpha, not playable. Phase 0 only — dat file asset inventory.
Faithful port of the retail client's behaviour to Silk.NET with a modern,
plugin-friendly architecture. The code is modern; the behaviour is retail.
**Stack:** .NET 10, [Chorizite.DatReaderWriter](https://github.com/Chorizite/DatReaderWriter) for dat parsing. Silk.NET + Avalonia planned for rendering/UI (not yet wired up).
**Status:** playable pre-alpha. You can log in to an ACE server, walk and
run through Dereth, see other players animate correctly, watch the
day-night cycle, hear ambient audio, and take weapons out. Many systems
are still stubbed or in-progress — see roadmap.
**Requires:** A retail Asheron's Call install (Turbine/Microsoft property — supply your own). Set `ACDREAM_DAT_DIR` environment variable to the directory containing `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and `client_local_English.dat`, or pass it as the first CLI argument.
## Stack
## Layout
- **Language:** C# .NET 10
- **Graphics:** [Silk.NET](https://github.com/dotnet/Silk.NET) (OpenGL 4.3)
- **Audio:** OpenAL via Silk.NET
- **Dat parsing:** [Chorizite.DatReaderWriter](https://github.com/Chorizite/DatReaderWriter)
- **Networking:** Custom UDP + ISAAC cipher + game-message layer, wire-compatible
with ACEmulator server
- `src/AcDream.Cli/` — console app that dumps asset counts from a dat directory
- `references/` — local read-only reference material (ACE, ACViewer, WorldBuilder, DatReaderWriter, holtburger, retail AC install). Gitignored.
## What works
## Run
- Connecting to a local ACEmulator (ACE) server on `127.0.0.1:9000`
- Character selection and login
- Rendering Dereth terrain with retail-correct texture blending,
per-vertex lighting, and road overlays
- Static scenery (buildings, trees, scenery objects) via EnvCell walker
- Animated characters (own + remote) with walk / run / strafe / jump /
turn / attack motions sourced from the retail motion tables
- Network sync with remote players — you can watch other characters
animate correctly, including speeds and directional motion
- Day-night cycle driven from the retail Region dat (0x13000000) —
correct DayGroup picking via the retail LCG, correct keyframe
interpolation, correct per-keyframe sky-object replace
- Weather (rain/snow particles synced from the server via the retail
DayGroup name)
- Sky dome, stars, moon, clouds, sun — each rendered from the retail
Region's SkyObjects with texture scrolling and alpha fade
- Plugin host with live event replay-on-subscribe
## What's stubbed or in-progress
- Indoor transitions (building interiors) — disabled, Phase B.3 pending
- Combat — animation works, damage math not wired
- Lightning visual — the retail PhysicsScript-driven flash is researched
but not wired (see `docs/research/2026-04-23-lightning-real.md`)
- TimeSync drift — we only sync calendar on login, not periodically,
so acdream's in-game clock gradually drifts from retail's
- Landscape draw distance — currently `ACDREAM_STREAM_RADIUS=2` (~400m)
vs retail's several kilometres
See `docs/plans/2026-04-11-roadmap.md` for the ordered phase list.
## Building + running
**Requires:**
- .NET 10 SDK
- A retail Asheron's Call dat directory (Turbine/Microsoft property —
supply your own). Contains `client_portal.dat`, `client_cell_1.dat`,
`client_highres.dat`, `client_local_English.dat`.
- A running ACE (ACEmulator) server on `127.0.0.1:9000` (or override
via env var)
**Launch (PowerShell on Windows — bash has trouble with the apostrophe
in "Asheron's Call"):**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug
```
Offline CLI dat inspector (no server needed):
```
dotnet run --project src/AcDream.Cli -- "C:\path\to\Asheron's Call"
```
Or set `ACDREAM_DAT_DIR` and run without args.
## Diagnostic env vars
| Variable | Effect |
|---|---|
| `ACDREAM_DUMP_SKY=1` | Per-second dump of the interpolated `SkyKeyframe` values + per-SkyObject draw info + texture alpha histograms |
| `ACDREAM_DUMP_MOTION=1` | Dump every inbound `UpdateMotion` + resulting `SetCycle` |
| `ACDREAM_STREAM_RADIUS=N` | Tune landblock visible-window radius (default 2 = 5×5) |
| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL init |
| `ACDREAM_DAY_GROUP=N` | Force a specific DayGroup index for A/B-testing weather presets |
| `ACDREAM_RUN_SKILL=N` / `ACDREAM_JUMP_SKILL=N` | Client-side run/jump skill (default 200) |
## Layout
```
src/
AcDream.App/ rendering + audio + main loop (Silk.NET)
AcDream.Core/ game state, meshing, physics, sky, weather, lighting
AcDream.Core.Net/ UDP + ISAAC + game-message layer
AcDream.Cli/ offline dat-inspector console app
AcDream.Plugin.Abstractions/ plugin host interfaces
AcDream.Plugins.Smoke/ example plugin
tests/
AcDream.Core.Tests/ xUnit tests (742 passing)
AcDream.Core.Net.Tests/ network-layer tests
tools/
RetailTimeProbe/ Win32 P/Invoke ReadProcessMemory probe of
the live retail acclient.exe — dumps
TimeOfDay + sky-lighting globals so we
can compare against acdream's state
SkyObjectInspect/ dat-inspector for Region sky objects
references/ vendored read-only reference code — ACE,
ACViewer, WorldBuilder, holtburger,
AC2D, Chorizite, DatReaderWriter.
Gitignored.
docs/
architecture/ single-source-of-truth architecture doc
plans/ phase roadmaps + per-phase specs
research/ decompile-derived research, per-phase
findings, deep-dive agent reports
audit/ phase-completion audits
```
## Development workflow
All AC-specific behaviour is ported from the decompiled retail client
(`docs/research/decompiled/`). The workflow is:
1. **Decompile first.** Find the matching function in the decompiled
client.
2. **Cross-reference.** Check against ACE's C# port and ACViewer /
WorldBuilder.
3. **Write pseudocode.** Translate C to readable pseudocode first.
4. **Port faithfully.** Translate line-by-line, preserving variable
names and control flow.
5. **Conformance test.** Add tests using golden values from retail.
6. **Integrate surgically.** Minimise churn in the surrounding pipeline.
Guessing at AC-specific algorithms is explicitly forbidden — see
`CLAUDE.md` for the full workflow rationale and the list of failure
modes we've paid for in the past.
## Reference repos
We cross-reference five external projects for every retail behaviour:
- **ACE** (ACEmulator) — authoritative server-side protocol
- **ACViewer** — MonoGame dat viewer; good for character appearance
- **WorldBuilder** — Silk.NET dat editor; matches our stack
- **Chorizite.ACProtocol** — clean-room C# protocol library
- **holtburger** — most complete non-retail client; Rust TUI, full
client-side behaviour
- **AC2D** — C++ AC-client emulator; has the real terrain split
formula and 0xF61C movement packet format
See `CLAUDE.md` for which reference is authoritative for which domain.
## Licence
Not yet chosen. All external reference code is vendored under its own
licence; see `references/*/LICENSE`. The acdream source code itself is
unreleased — not yet distributed to the public. Once the licence
choice is made it will go in a top-level `LICENSE` file.
The AC dat files and the game's intellectual property remain the
property of Microsoft / Turbine. This project does not distribute any
of those files or assets — you must supply your own retail install.

View file

@ -0,0 +1,136 @@
# Phase 5+ Port Plan — Sky / Weather / Lightning, retail-verbatim
**Date:** 2026-04-23
**Scope:** Port the remaining retail-accurate pieces of the sky/weather/lightning
system so acdream visually matches a side-by-side retail client in all
day/night + weather states (clear, cloudy, rainy, stormy).
## Where we are today (main, commit 2802fb2)
Sky core, landed across Phases 1-4b:
- Region-dat SkyDesc loader with GameTime offsets ✓
- Retail LCG DayGroup picker (seed = Year × DaysPerYear + DayOfYear, Phase 3g) ✓
- Calendar tick extraction with `GameTime.ZeroTimeOfYear = 3600` (Phase 3f) ✓
- Per-vertex D3D-fixed-function lighting formula (Phase 4, Phase 4b clamp) ✓
- Sky objects drawn with visibility, arc sweep, UV scroll ✓
- ACDREAM_DUMP_SKY diagnostic for retail-faithfulness verification ✓
- RetailTimeProbe tool for live memory comparison ✓
Left to do:
1. **PhysicsScript** — no loader, no runtime, no sky-side integration. User-visible:
rain doesn't spawn when retail rolls a PES-carrying SkyObject.
2. **Fog on sky** — shader ignores fog uniforms; retail's D3D fog applies to sky.
3. **Lightning flash trigger** — storm timer + visual not ported.
4. **Weather / DayGroup crossfade** — retail's 10-second smooth blend between
keyframe sets not ported.
5. **AdminEnvirons override** — packet handler exists as a stub on the wire side;
not wired to our rendering.
## Phases (execute in order)
### Phase 5 — PhysicsScript loader + runtime + sky wiring
Output of parallel research agents #1 + #2 (2026-04-23):
- `2026-04-23-physicsscript.md` — dat schema + runtime interpreter
- `2026-04-23-sky-pes-wiring.md` — sky → PES lifecycle
Sub-phases:
- **5a** Port `PhysicsScript` dat type + any nested types. Add to `AcDream.Core/Dat/`.
- **5b** Port the runtime interpreter to C#. `AcDream.Core/Vfx/PhysicsScriptRunner.cs`.
Wire into existing `ParticleSystem` as the spawner — we do NOT build a new
emitter class, reuse what's there.
- **5c** Hook into `SkyRenderer` → on per-frame sky-object iteration, for each
visible SkyObject with non-zero `DefaultPesObjectId`, ensure its PES is running.
Despawn on visibility loss or DayGroup change.
- **5d** Replace `WeatherSystem.SetKindFromDayGroupName`'s crude
`"Rainy" → WeatherKind.Rain` string match with PES-driven spawning. The
`WeatherKind` enum becomes fog/tone info only; particle emission is
100% PES-gated.
Tests: PhysicsScript parser conformance (golden bytes → expected struct),
runtime determinism (same script + same seed → same particle stream).
### Phase 6 — Fog on sky meshes
Output of research agent #4: `2026-04-23-sky-fog.md`.
Sub-phases:
- **6a** `sky.vert` computes fog factor per vertex. Formula from the agent's
findings (expected: linear per-vertex based on eye-space Z).
- **6b** `sky.frag` applies `mix(fragment, fogColor, fogFactor)` before the
lightning-flash bump.
- **6c** If sky meshes render at distances that saturate the keyframe's
FOGEND (sky would be pure fog color), either:
- Cap sky mesh eye-space Z at FOGEND - epsilon for fog purposes only, OR
- Use a separate "sky fog" distance parameter per retail's behavior.
Tests: render-golden at 4 canonical times (dawn/noon/dusk/midnight) + 3
DayGroups (Sunny / Cloudy / Stormy) — compare against retail screenshots.
### Phase 7 — Lightning flash trigger
Output of research agent #3: `2026-04-23-lightning-crossfade.md` (shared with
Phase 8 findings).
Sub-phases:
- **7a** Port retail's storm-keyframe lightning timer.
- **7b** Wire to existing `uFogParams.z` lightning-flash uniform in the UBO
(sky.frag already consumes it).
- **7c** Wire thunder audio cue via `AdminEnvirons.Thunder1Sound..Thunder6Sound`
or a local per-flash delay (retail uses speed-of-sound distance).
### Phase 8 — Weather / DayGroup crossfade
Also from agent #3.
Sub-phases:
- **8a** Port `DAT_008427a9` flag + `_DAT_008427b8` progress mechanics into
our SkyStateProvider or a new CrossfadeOrchestrator class.
- **8b** Trigger a crossfade when:
- DayGroup index changes (day rollover hits a new weather roll) — smooth
swap of keyframe set over retail's step constant `_DAT_007c7208`.
- `AdminEnvirons` override arrives — smooth fog transition to the override
color.
- **8c** AdminEnvirons wiring: the packet handler stub in `WeatherSystem.Override`
already exists; wire it to the crossfade trigger + our renderer.
### Optional Phase 9 — Per-cell AdjustPlanes terrain relight
From earlier research (`2026-04-23-sky-decompile-hunt-A.md` §1): retail reruns
`FUN_00532440` on every terrain cell whenever the sky keyframe advances.
We currently bake terrain vertex lighting once and don't refresh. Visible effect:
terrain doesn't darken smoothly as the sun sets.
Deferred because it's higher effort and lower payoff than 5-8.
## Success criteria
1. A `+Acdream` character stationary in outdoor Holtburg for 30 real minutes
(about 15 Derethian minutes with our 1:1 tick rate) produces a sky that,
side-by-side with retail, is visually indistinguishable within lighting
equipment tolerances (color temperature, saturation).
2. Rolling a DayGroup that contains a rain-emitting SkyObject causes
acdream to spawn rain particles MATCHING retail's rain cadence (drop rate,
direction, lifetime).
3. During a Stormy DayGroup, acdream shows lightning flashes at the retail
cadence (830 sec between strikes, flash rises in ~50ms, decays in ~200ms).
4. An `AdminEnvirons RedFog` packet arriving mid-play crossfades acdream's fog
to the red tint within ~10 real seconds, same direction retail does.
## Non-goals (for this plan)
- **PhysicsScript author tools** — we parse + run; we don't edit.
- **Retail-accurate GPU particle rendering** — reuse our existing
`ParticleSystem` backend. PhysicsScript drives IT, not a new emitter.
- **Exotic EnvironChangeTypes** (the Thunder3Sound, DarkLaughSound, etc.
non-fog variants) — those are admin-only and we can stub-log them.
- **Per-landblock weather variation** — retail weather is Dereth-wide.
## Open risks / unknowns (to be resolved by agents)
- Will our `ParticleSystem.SpawnEmitter` API be sufficient, or does retail's
PhysicsScript need commands we don't expose? (agent #1).
- Does sky mesh vertex data need a 1e6-far-plane fog distance rescaling, or
is retail's FOGEND authored large enough to cover sky? (agent #4).
- Does retail sync the lightning random timer across all clients (so everyone
sees the same strike), or is it truly client-local? (agent #3).

View file

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

View file

@ -0,0 +1,398 @@
# 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`:
```c
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`:
```c
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`:
```c
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`):
```csharp
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:
```c
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)
```csharp
// 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)
```csharp
// 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-12336``FUN_006adba0` opcode 0xF754 dispatcher
- `docs/research/decompiled/chunk_00450000.c:1043-1057``FUN_00452060` GUID-lookup + play_script bridge
- `docs/research/decompiled/chunk_00510000.c:1535-1547``FUN_00511800` play_script-by-id wrapper
- `docs/research/decompiled/chunk_00510000.c:1504-1531``FUN_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-7299``FUN_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-64``PlayScriptId = 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.

View file

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

View file

@ -4,6 +4,13 @@
**Hunter:** Hunt Agent B (render-state signatures)
**Status:** SIGNIFICANT FINDINGS — but NOT a "celestial-body iteration draw loop"
> **⚠ 2026-04-24 correction:** Any occurrences in this doc that call
> `DAT_00842778` the "ambient" colour are backwards. `DAT_00842778` =
> **DirColor** (directional / sun), `DAT_0084277c` = **AmbColor**,
> `DAT_00842780` = **AmbBright**. Cross-verified against
> `SkyTimeOfDay.Unpack` and `FUN_00501600`'s output mapping. Full
> re-analysis: `docs/research/2026-04-24-lambert-brightness-split.md`.
## TL;DR
The retail acclient does NOT appear to have a classical "sky dome + iterate

View file

@ -9,6 +9,31 @@ All citations use `{chunk_file}:{line}` relative to the decompile tree.
---
## ⚠ 2026-04-24 correction
Sections §1, §2, §5 of this doc label `DAT_00842778` as "AmbColor" and
`DAT_0084277c` as "DirColor/Fog". **That labeling is backwards.** The
correct mapping — cross-verified against the DatReaderWriter schema
(`SkyTimeOfDay.Unpack` field order) and the `FUN_00501600` output map:
- `DAT_00842778` = **DirColor** (directional/sun color ARGB)
- `DAT_0084277c` = **AmbColor** (ambient color ARGB)
- `DAT_00842780` = **AmbBright** (ambient brightness scalar, *not* fog start)
The `FUN_00532440` per-vertex Lambert at `chunk_00530000.c:2118-2124`
reads `DAT_00842778` as the N·L-modulated color (→ directional) and
`DAT_0084277c × DAT_00842780` as the flat / brightness-scaled color
(→ ambient × ambBright). The pre-multiply at line 2107 takes
`DAT_00842780 * DAT_0084277c` which is the textbook "ambient scalar ×
ambient color" retail ambient term.
See `docs/research/2026-04-24-lambert-brightness-split.md` for the full
re-analysis and `SkyTimeOfDay.generated.cs` for the field offsets (+0x10
DirColor, +0x18 AmbColor). All entries below should be read with this
swap in mind; the decompile math quotes themselves are correct.
---
## 1. Global Inventory — the sky state block
All globals live in a contiguous block at **`0x00842778..0x008427c0`** with a second cluster at **`0x00842950..0x00842960`**. Every field is read by landblock/draw code and written only by the per-frame updater `FUN_005062e0` via the interp delegate `FUN_00501600`. Initial values are set in `FUN_00505dd0` (the sky-system constructor).

View file

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

View file

@ -0,0 +1,441 @@
# Sky Material/D3D State — Retail Decompile Trace
**Date:** 2026-04-23
**Scope:** Q1Q6 of the material/state hunt. Pins exactly what retail writes
per-mesh when rendering a sky GfxObj, and what stays inherited from scene state.
## TL;DR — the retail sky fragment formula
Retail D3D **fixed-function lighting** is the sky's colour source. Per-sky-mesh,
the retail client writes a `D3DMATERIAL9` with fields populated from the mesh's
`Surface` (the per-Surface luminosity/maxBright/transparency). Sky meshes do
NOT get a special state pass — they ride the normal mesh pipeline.
Per-fragment (fixed-function pseudocode):
```
material.Diffuse = (0, 0, 0, 1) if Surface.Luminous else (from FUN_0059da60)
material.Ambient = (0, 0, 0, 1) if Surface.Luminous else (from FUN_0059da60)
material.Emissive = (Lum, Lum, Lum, 1) where Lum = Surface.Luminosity or 0
vertex.diffuse.rgb = <per-vertex lit by AdjustPlanes>
vertex.diffuse.a = 1 - Surface.Transparency (for each of 4 corners)
if D3DRS_LIGHTING:
# D3D fixed-function lighting:
litColor = material.Emissive
+ material.Ambient * (D3DRS_AMBIENT + sum_of_light.ambient)
+ material.Diffuse * sum_of_light.diffuse * dot(N, L)
+ material.Specular * ...
else:
# Lighting OFF — vertex.diffuse is used directly.
litColor = vertex.diffuse
fragment.rgb = texture.rgb * litColor.rgb
fragment.a = texture.a * litColor.a
if D3DRS_FOGENABLE and z > FOGSTART:
fragment.rgb = lerp(fragment.rgb, D3DRS_FOGCOLOR,
clamp((z - FOGSTART)/(FOGEND - FOGSTART), 0, 1))
```
Key facts:
1. **No sky-specific render-state toggles.** Sky meshes render with whatever
D3DRS_LIGHTING, D3DRS_FOGENABLE, D3DRS_AMBIENT were last set. The per-mesh
writer `FUN_0059da60` MAY flip LIGHTING on/off based on a global flag.
2. **Luminous flag (`piVar6[5] < 0`) zeroes Diffuse+Ambient**, effectively
making the mesh render as `Emissive-only * texture`. Non-luminous uses the
full lighting equation.
3. **Surface.Luminosity is written to `D3DMATERIAL9.Emissive.rgb`.** Confirmed
at `chunk_00590000.c:10669-10674`.
4. **Surface.Transparency is written to 4 per-vertex alpha slots** (one per
corner of a quad Surface), via `FUN_0053a430` at `chunk_00530000.c:7706-7715`.
5. **Fog stays ENABLED during the sky render.** The keyframe fog range
(MinWorldFog → MaxWorldFog) is likely tuned so sky geometry at its rendered
distance is not heavily fogged.
## Q1 — Fog state during sky render
**Answer: Fog stays ENABLED.** Retail does not toggle fog around the sky pass.
Evidence: I searched every call to `FUN_005a3f90` (D3DRS_FOGENABLE writer).
All call sites:
```
chunk_00500000.c:6293 FUN_005a3f90(DAT_0081dbf8); # FUN_005062e0 per-frame master gate
chunk_00500000.c:7270 FUN_005a3f90(DAT_008427a9 != '\0'); # FUN_00507a50 weather-volume pass
chunk_00500000.c:7295 FUN_005a3f90(cVar4 != '\0'); # FUN_00507a50 restore
chunk_005A0000.c:707 FUN_005a3f90(0); # device-init default
chunk_005A0000.c:1344 FUN_005a3f90(DAT_008ee545); # device-reset
```
`FUN_00508010` (sky render) does NOT call `FUN_005a3f90`. The per-frame master
gate at `FUN_005062e0:6291` fires BEFORE the sky render inside the same function:
```c
// chunk_00500000.c:6235-6333 FUN_005062e0
if (*(int *)(param_1 + 0x10) != 0) {
if (*(int *)(param_1 + 0x20) != 0) {
FUN_00508010(); // sky render
}
...
FUN_005a4010(DAT_0081dbf8 == '\0'); // master fog gate, NOT disable
if (DAT_0081dbf8 != '\0') {
FUN_005a3f90(DAT_0081dbf8); // FOG = ON if master flag set
...lerp fog...
FUN_005a41b0(&fogColor, fogNear, fogFar); // write FOGCOLOR/START/END
}
}
```
The fog is master-controlled by `DAT_0081dbf8` (application-level toggle). When
outdoors it is typically ON.
**The sky meshes render THROUGH fog.** If the sky GfxObj's far-placement
distance exceeds FOGEND, the fog color will dominate. This is why retail keys
MinWorldFog/MaxWorldFog per-SkyTimeOfDay — to tune how fog bleeds into the sky.
## Q2 — What FUN_0059da60 writes per-mesh (the real per-Surface state setter)
**FUN_00514b90 is only a transform-enqueue wrapper. The real per-Surface
material/D3D state writer is `FUN_0059da60` at `chunk_00590000.c:10586-10795`**,
called downstream by the scene-graph flush. Critical region:
```c
// chunk_00590000.c:10641-10689
FUN_005a3d80((DAT_008ee070 == 0) + '\x01'); // D3DRS_CULLMODE
if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) {
uVar12 = 1;
} else {
uVar12 = 0;
}
FUN_005a41f0(uVar12); // D3DRS_LIGHTING ← uVar12
if ((char)piVar6[5] < '\0') { // Surface.Luminous flag
FUN_005a4310(1);
if (*(int *)(DAT_00870340 + 0x7e4) == 0) {
_DAT_008ee03c = DAT_00821e38; // D3DMATERIAL9.Diffuse.A = 0
_DAT_008ee044 = 0x3f800000; // D3DMATERIAL9.Ambient.A = 1.0f
_DAT_008ee038 = DAT_00821e38; // D3DMATERIAL9.Ambient.R = 0
_DAT_008ee040 = DAT_00821e38; // D3DMATERIAL9.Ambient.B = 0
_DAT_008ee02c = DAT_00821e38; // D3DMATERIAL9.Diffuse.G = 0
_DAT_008ee028 = DAT_00821e38; // D3DMATERIAL9.Diffuse.R = 0
_DAT_008ee030 = DAT_00821e38; // D3DMATERIAL9.Diffuse.B = 0
_DAT_008ee034 = 0x3f800000; // D3DMATERIAL9.Diffuse.A = 1.0f (overwrite)
(**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4))
(*(int **)(DAT_00870340 + 0x468), &DAT_008ee028); // SetMaterial
FUN_005a3ef0(0); // D3DRS_COLORVERTEX = 0 (ignore vertex colour)
FUN_005a3f40(0); // (state 0x93)
}
}
else if (DAT_00796344 < *(float *)(param_2 + 0x78)) { // Surface.Luminosity > 0
iVar8 = *(int *)(DAT_00870340 + 0x7e4);
if (iVar8 == 0) {
DAT_008ee058 = *(undefined4 *)(param_2 + 0x78); // Emissive.R = Luminosity
DAT_008ee064 = 0x3f800000; // Emissive.A = 1.0f
DAT_008ee05c = DAT_008ee058; // Emissive.G = Luminosity
DAT_008ee060 = DAT_008ee058; // Emissive.B = Luminosity
(**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4))
(*(int **)(DAT_00870340 + 0x468), &DAT_008ee028); // SetMaterial
}
}
```
**Material-block global at `DAT_008ee028` — mapped byte-for-byte to D3DMATERIAL9:**
| Offset from 0x008ee028 | Global | D3DMATERIAL9 field |
|---|---|---|
| +0x00 | DAT_008ee028 | Diffuse.R |
| +0x04 | DAT_008ee02c | Diffuse.G |
| +0x08 | DAT_008ee030 | Diffuse.B |
| +0x0c | DAT_008ee034 | Diffuse.A |
| +0x10 | DAT_008ee038 | Ambient.R |
| +0x14 | DAT_008ee03c | Ambient.G |
| +0x18 | DAT_008ee040 | Ambient.B |
| +0x1c | DAT_008ee044 | Ambient.A |
| +0x20..0x2c | DAT_008ee048..054 | Specular.RGBA (not touched in this hunt) |
| +0x30 | DAT_008ee058 | **Emissive.R = Luminosity** |
| +0x34 | DAT_008ee05c | **Emissive.G = Luminosity** |
| +0x38 | DAT_008ee060 | **Emissive.B = Luminosity** |
| +0x3c | DAT_008ee064 | **Emissive.A = 1.0f** |
**Verification of offsets:** luminous path sets +0x0c (Diffuse.A) to 0 via
`_DAT_008ee03c = DAT_00821e38`. Wait — that's at +0x0c from 0x008ee028 = 0x008ee034.
Let me re-read: line 10652 sets `_DAT_008ee03c`; line 10659 sets `_DAT_008ee034`
to 1.0f. The former is 0x14 bytes in (Ambient.G); the latter is 0x0c (Diffuse.A).
Reconciling: the luminous path sets Diffuse R=G=B=0, A=1 (via DAT_008ee02c, 028,
030, 034 all at +0x00..0x0c), Ambient R=G=B=0, A=1 (DAT_008ee038, 03c, 040, 044
at +0x10..0x1c). Then `SetMaterial` pushes the whole block — but crucially
**Emissive at +0x30..0x3c is UNCHANGED from whatever the previous caller left
it at** for luminous meshes. This is a subtle retail bug/feature: if the
preceding draw set Emissive to some value, the next luminous draw inherits it.
For non-luminous with Luminosity > 0 (the "else if" branch, line 10666), only
Emissive is updated — Diffuse/Ambient are left from the prior `FUN_0059d520`
call or from some other writer.
**Referenced writer `FUN_0059d520` at line 10636** is where Diffuse/Ambient
get set for normal rendering (texture-modulated). Not fully traced here — but
confirmed: Diffuse/Ambient are NOT zero for non-luminous meshes.
## Q3 — FUN_00512360/124b0/120c0 + FUN_00518e70/ee0/f50 + FUN_0050f040/0c0/140
These are the **PhysicsPart per-part setters** called by the sky render loop.
Each is a "set or enqueue-animation" pair. Chain:
```
FUN_00508010 (sky object render loop)
→ FUN_00512360(part, Luminosity, 0, 0) # "set or animate Luminosity"
├── [animated] FUN_0051c580(3, ...) # animation keyframe schedule
└── [immediate] FUN_00518ee0(Luminosity)
→ foreach Surface in part: FUN_0050f0c0(Surface, Luminosity)
→ writes Surface.offset_0xd4 = Luminosity (PhysicsPart +0xd4)
→ if active: FUN_0053a460(material_cache, Luminosity)
→ writes cache +0x3c, +0x40, +0x44 = Luminosity, Luminosity, Luminosity
→ FUN_005124b0(part, MaxBright, 0, 0) # same pattern for MaxBright → +0xd0 → FUN_0053a490 → cache +0x0c, +0x10, +0x14
→ FUN_005120c0(part, Transparency, 0, 0) # same pattern for Transparency → +0xcc → FUN_0053a430 → cache +0x18, +0x28, +0x38, +0x48 (alpha for 4 verts, stored as 1-Transparency)
```
File:line evidence:
```c
// chunk_00510000.c:2267-2298 FUN_00512360 (Luminosity set-or-animate)
if (_DAT_007c78bc <= (float)(double)CONCAT44(param_5,param_4)) {
// animation branch — enqueue keyframe
iVar3 = FUN_0051c580(3, ...);
...
}
else if (*(int *)(param_1 + 0x10) != 0) {
FUN_00518ee0(param_3); // immediate apply
}
// chunk_00510000.c:7901-7915 FUN_00518ee0 (Luminosity broadcast to Surfaces)
void FUN_00518ee0(int param_1, undefined4 param_2) {
if ((*(int *)(param_1 + 0x54) != 0) && (uVar1 = 0, *(int *)(param_1 + 0x58) != 0)) {
do {
if (*(int *)(*(int *)(param_1 + 0x5c) + uVar1 * 4) != 0) {
FUN_0050f0c0(param_2); // per-Surface Luminosity set
}
uVar1 = uVar1 + 1;
} while (uVar1 < *(uint *)(param_1 + 0x58));
}
}
// chunk_00500000.c:13557-13582 FUN_0050f0c0 (PhysicsPart.Luminosity write)
if (param_2 != *(float *)(param_1 + 0xd4)) {
*(float *)(param_1 + 0xd4) = param_2; // PhysicsPart +0xd4 = Luminosity
...
iVar2 = FUN_0050e100();
if (iVar2 != 0) {
FUN_0053a460(param_2); // material cache broadcast
}
}
// chunk_00530000.c:7732-7741 FUN_0053a460 (material cache: 3-float slot)
void FUN_0053a460(int param_1, undefined4 param_2) {
*(undefined4 *)(param_1 + 0x3c) = param_2;
*(undefined4 *)(param_1 + 0x40) = param_2;
*(undefined4 *)(param_1 + 0x44) = param_2;
}
```
Same chain for MaxBright (`FUN_005124b0 → FUN_00518f50 → FUN_0050f040 → +0xd0 →
FUN_0053a490`) and Transparency (`FUN_005120c0 → FUN_00518e70 → FUN_0050f140 →
+0xcc → FUN_0053a430`). The Transparency writer applies `alpha = 1 -
Transparency` to FOUR alpha slots at `+0x18, +0x28, +0x38, +0x48` (one per
corner of a quad-Surface's 4 vertices).
**Interpretation:** `FUN_0053a4b0` initializes this cache struct with eight
consecutive `1.0f` values at `param_1[3..10]` (offsets +0x0c..+0x28). This is a
**per-Surface fixed-function render cache** holding material-like data for 4
vertices. The fields:
| Offset | Field | Set by |
|---|---|---|
| +0x0c, +0x10, +0x14 | MaxBright R, G, B (3 floats) | FUN_0053a490 |
| +0x18, +0x28, +0x38, +0x48 | vertex alpha v0, v1, v2, v3 (1-Transparency) | FUN_0053a430 |
| +0x3c, +0x40, +0x44 | Luminosity R, G, B | FUN_0053a460 |
**This is NOT a D3DMATERIAL9.** It's retail's bespoke per-Surface colour cache.
The Surface.Luminosity/MaxBright/Transparency set on PhysicsPart via
`FUN_00512360/124b0/120c0` gets stored in:
1. PhysicsPart struct (+0xcc, 0xd0, 0xd4) — persistent part state.
2. Per-Surface material cache (+0x3c.., +0x0c.., +0x18..) — render-time values.
Then when `FUN_0059da60` builds the actual D3DMATERIAL9 to submit to D3D, it
reads `param_2 + 0x78` = Surface.Luminosity — this is the **Surface-level**
Luminosity (from the dat), NOT the animated PhysicsPart Luminosity. The cache
struct's Luminosity (+0x3c..) is for a different purpose — likely per-vertex
colour modulation when COLORVERTEX is on (see Q5). I did NOT find the exact
consumer of cache +0x3c within the 60-minute budget — it may flow into vertex
colour on the vertex-fill path.
Plainly: **retail sky's per-mesh luminosity overrides are stored in two places
and consumed by two different stages (material push for non-luminous meshes,
per-vertex colour cache for others).**
## Q4 — D3DRS_LIGHTING during sky pass
**D3DRS_LIGHTING is ON for normal meshes (including sky), OFF for the
weather-volume overlay (rain/snow/fog cells).**
Evidence: `FUN_0059da60` at `chunk_00590000.c:10642-10648` sets LIGHTING ON
unless a global override forces it off:
```c
if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) {
uVar12 = 1; // ← LIGHTING = ON
} else {
uVar12 = 0; // ← LIGHTING = OFF
}
FUN_005a41f0(uVar12); // D3DRS_LIGHTING ← uVar12
```
`DAT_008ee06c` is the "rendering flag" set in various places — when its value
is 0 (default), LIGHTING = 1. The `DAT_00870340 + 0x7e0` flag is a secondary
override. Practically: lighting is ON for all visible mesh draws.
**Corollary:** Since LIGHTING is ON, the material fields (Diffuse, Ambient,
Emissive) drive the output. With Diffuse=0 and Emissive=Luminosity (the luminous
branch), output = texture × Luminosity. With Diffuse!=0 and Emissive=Luminosity
(non-luminous branch with Surface.Luminosity), output = texture × (Emissive +
Diffuse × dot(N, L) × sunLight + Ambient × AMBIENT).
Device-init default at `chunk_005A0000.c:709` sets `FUN_005a41f0(0)` (LIGHTING
OFF), but this is the startup state; scene render flips it per-mesh.
## Q5 — Sky-pass vs terrain-pass render state diff
Retail does NOT distinguish sky from terrain at the render-state level. Both
go through `FUN_0059da60` (per-mesh state setter). Per-draw state that CAN
differ, all driven by Surface flags or globals:
| D3D state | Who flips it per draw | Varies per-sky-mesh? |
|---|---|---|
| CULLMODE (0x16) | `FUN_005a3d80` at 10641 | No — all meshes same (`DAT_008ee070` global) |
| LIGHTING (0x89) | `FUN_005a41f0` at 10648 | No — driven by `DAT_008ee06c` global |
| COLORVERTEX (0x91) | `FUN_005a3ef0` at 10662 (luminous path only) | **Yes** — luminous sky meshes set COLORVERTEX=0 |
| Material (SetMaterial, not a RS) | `(vtable+0xc4)` at 10660, 10673, 10686 | **Yes** — per-Surface Luminosity/flag |
| FOGENABLE (0x1c) | Only `FUN_005062e0` (per-frame gate) | No — set once per frame |
| AMBIENT (0x8b) | Only init (`FUN_005a3eb0(0)`) | No — always 0 |
| ZFUNC/ZWRITE (0x17/0x0e) | Only `FUN_00507a50` weather volume | No for sky proper |
**Conclusion for Q5: sky and terrain share state.** The ONLY per-draw divergence
for sky is via `Surface.Luminous` flag, which (a) zeroes Diffuse+Ambient,
(b) sets COLORVERTEX=0. Non-luminous sky meshes render identically to terrain
except for the material Emissive field.
**This means:** in retail, a cloud mesh (non-luminous) gets the same lighting
treatment as a grass vertex — `Emissive + Diffuse*dot(N,L)*sunColor +
Ambient*D3DRS_AMBIENT`. Since D3DRS_AMBIENT=0, the Ambient term drops; the
output is `Emissive + Diffuse × dot(N, L) × sunColor` — i.e. per-vertex
directional lighting.
A dome mesh (luminous) with `Surface.Luminosity = X` renders as
`Emissive(X,X,X) * texture` (no diffuse, no ambient) — essentially a fade
between off (X=0) and full-texture (X=1).
## Q6 — Verbatim formula for C# port
The retail sky fragment equation, per GfxObj Surface:
```
# Stage 1: Material + vertex-colour build
if Surface.Luminous:
material.Diffuse = (0, 0, 0, 1)
material.Ambient = (0, 0, 0, 1)
material.Emissive = (Lum, Lum, Lum, 1) # Lum = Surface.Luminosity
vertexColour = white # COLORVERTEX = 0
else:
material.Diffuse = surfaceBaseDiffuse # from Surface texture modulate
material.Ambient = surfaceBaseAmbient # likely (1,1,1,1) default
material.Emissive = (Lum, Lum, Lum, 1) # Lum = Surface.Luminosity (≥ 0)
vertexColour = <per-vertex AdjustPlanes output> # pre-lit per-vertex
# Stage 2: D3D fixed-function lighting (LIGHTING = ON; AMBIENT = 0)
litColour = material.Emissive
+ material.Diffuse * D3DLight.Diffuse * dot(N, -sunDir) # sunDir from FUN_00501600
+ material.Ambient * 0 # AMBIENT=0, drops out
# Specular ignored (0)
# Stage 3: Texture modulate + vertex colour
fragment.rgb = texture.rgb * litColour.rgb * vertexColour.rgb
fragment.a = texture.a * litColour.a * vertexColour.a
# Stage 4: Fog blend (FOGENABLE = ON per master)
if z > FOGSTART:
t = clamp((z - FOGSTART) / (FOGEND - FOGSTART), 0, 1)
fragment.rgb = lerp(fragment.rgb, FOGCOLOR, t)
```
**For the acdream sky shader, this reduces to:**
```glsl
// For LUMINOUS sky sub-meshes (dome, sun, moon, stars if Luminous=true):
fragment = texture(uSky, uv) * vec4(uLuminosity, uLuminosity, uLuminosity, 1.0) * uTransparency;
// where uLuminosity = Surface.Luminosity (0..1 fraction)
// and uTransparency is the keyframe-override-animated 1-Transparency.
// NO ambient multiplication. NO sun-direction. No fog.
// For NON-LUMINOUS sky sub-meshes (typical clouds):
vec3 diffuseTerm = diffuseColour * sunColour * max(0, dot(N, -sunDir));
vec3 emissiveTerm = vec3(uLuminosity); // usually 0 for clouds
vec3 lit = emissiveTerm + diffuseTerm; // D3DRS_AMBIENT=0 drops that term
fragment.rgb = texture(uSky, uv).rgb * lit;
fragment.a = texture(uSky, uv).a * (1 - transparency);
// Optional fog: retail leaves fog ENABLED, but sky distance vs FOGEND
// determines whether fog contribution is visible. For acdream, first port
// assume sky is rendered NEAR clip so fog doesn't dominate.
```
**Immediate actionable change for acdream:**
1. Our current `fragment = texture × uLuminosity × uTint` (uTint=white) matches
retail for **luminous** sub-meshes. Correct behaviour — the over-bright
observation is NOT from tinting.
2. **The over-bright problem is almost certainly that our Luminosity values are
wrong.** Previous fix scaled dat values / 100 (percent→fraction). Retail does
`Surface.Luminosity × _DAT_007a1870`. If `_DAT_007a1870 = 1.0f` (strong
evidence: it's used as the "default/identity" return in FUN_00518c00/c20),
AND the dat values are in [0..1], retail renders `texture × dat_luminosity`
with NO /100 scaling. Our /100 would then be UNDER-bright. But user says
we're OVER-bright — so the dat values ARE in percent, 0..100, and our /100
scaling is correct.
3. **However, we may be applying Luminosity twice, or not applying it to the
right meshes.** Dome at dusk has Luminosity that INTERPOLATES (from the
SkyObjectReplace keyframe) — currently a constant 1.0 in our renderer
would render too bright.
4. **Non-luminous clouds** should get `texture × (Emissive + Diffuse × dot(N,
-sun) × sunColour)` — not `texture × 1`. Our clouds being "too bright" is
consistent with us skipping the diffuse-dot-sun shading entirely.
## Remaining uncertainty
- `_DAT_007a1870` exact value — evidence leans to 1.0f (identity), so our C#
port should treat dat Luminosity/Transparency/MaxBright as **already in the
right units** (no /100) and feed them directly. But user observation requires
a /100 to look less bright, so either (a) dat values are in percent and
`_DAT_007a1870 = 0.01f`, or (b) our shader is applying Luminosity in an
additional place it shouldn't.
- The role of the per-Surface material cache struct (`FUN_0053a4b0` constructed,
+0x0c..+0x48 fields) in the final fragment colour. It's written by the
PhysicsPart L/MB/T animation setters, but I didn't track its consumer to D3D.
Likely feeds COLORVERTEX-ON vertex alpha/RGB for non-luminous meshes.
- Whether `param_2 + 0x78` (Surface.Luminosity in FUN_0059da60) is the same
float as `PhysicsPart +0xd4` (Luminosity set by FUN_0050f0c0). The dual-path
suggests they're distinct — one is Surface-level (from the dat), one is
PhysicsPart-level (animated override).
## 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:13524-13617` FUN_0050f040/0c0/140 (PhysicsPart T/L/MB fields)
- `chunk_00510000.c:2115-2376` FUN_005120c0/12360/124b0 (set-or-animate entry)
- `chunk_00510000.c:4563-4591` FUN_00514b90 (transform enqueue — NOT the material writer)
- `chunk_00510000.c:7865-7963` FUN_00518e70/ee0/f50 (Surface broadcast)
- `chunk_00530000.c:7702-7764` FUN_0053a430/460/490 (per-Surface material cache fill)
- `chunk_00590000.c:10586-10795` FUN_0059da60 (the real per-mesh D3DMATERIAL9 + LIGHTING + COLORVERTEX writer)
- `chunk_005A0000.c:687-740` FUN_005a10f0 (device-init default state: LIGHTING=0, AMBIENT=0, FOG=0)

View file

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

View file

@ -0,0 +1,166 @@
# Retail Lambert — brightness split pseudocode
**Date:** 2026-04-24
**Owner:** lighting (terrain / mesh / sky)
**Decompile refs:** `chunk_00450000.c:2073` (`FUN_004530e0`), `chunk_00500000.c:6030` (`FUN_00505f30`), `chunk_00530000.c:1997` (`FUN_00532440` AdjustPlanes)
## Purpose
Retail's per-vertex lighting equation does **not** match what acdream is
currently shipping. Side-by-side screenshots show acdream as warmer /
less-blue than retail under the same DayGroup, and the 2026-04-24 user
investigation narrowed it to the **ambient component being static instead
of dynamic**. This doc captures the retail formula verbatim from the
decompile and maps it to concrete code changes.
## Retail globals (decompiled, names corrected)
CLAUDE.md currently labels these backwards. Walking the math in
`FUN_00532440`:
| Symbol | Real meaning | Source |
|---|---|---|
| `DAT_00842778` | **Directional color** (ARGB uint32) — multiplied by N·L per-vertex | `FUN_00505f30` param_5 |
| `DAT_0084277c` | **Ambient color** (ARGB uint32) — multiplied by `ambBright`, no N·L | `FUN_00505f30` param_3 |
| `DAT_00842780` | **Ambient brightness scalar** (float) | `FUN_00505f30` param_2 |
| `DAT_00842950/54/58` | **Sun direction** (vec3). Magnitude encodes sun intensity (not unit length). | `FUN_00505f30` param_4 |
| `DAT_00796344` | **Ambient floor** (float) — lower bound on N·L clamp. Retail ~0.08. | hardcoded constant |
| `DAT_007938c0` | **Ceiling** (float) = 1.0 — per-channel clamp | hardcoded |
| `DAT_00799208` | 1/255.0 — for unpacking ARGB bytes | hardcoded |
| `_DAT_008682b0/b4/b8` | Per-frame cache: `(ambBright + |sunDir|·scale) × ambColor.rgb` | Written by `FUN_004530e0`, read by `FUN_00532440` |
## Retail per-vertex formula (from FUN_00532440)
```
// Once per frame (FUN_00505f30 line 6067, FUN_004530e0):
effectiveAmbBright = ambBright + |sunDir| * scale // scale = _DAT_0079a1e8
ambPremul = effectiveAmbBright * ambColor // cached at _DAT_008682b0
// Per vertex (FUN_00532440 line 2118, iterated for all vertices):
NdotL = dot(sunDir, N) // sunDir NOT normalized
NdotL = max(NdotL, floor) // floor = DAT_00796344 (~0.08)
out.r = dirColor.r * NdotL + ambPremul.r
out.g = dirColor.g * NdotL + ambPremul.g
out.b = dirColor.b * NdotL + ambPremul.b
out = min(out, 1.0) // per-channel ceiling
```
Structure:
1. **Ambient term** = `(ambBright + |sunDir|·scale) × ambColor.rgb` — flat
per vertex, but changes per-frame as sun rises/falls.
2. **Directional term** = `dirColor × max(N·sunDir, floor)` where sunDir
keeps its length so N·L can exceed 1.0 when sun is strong overhead.
3. Final per-channel clamp to 1.0.
## acdream today (for contrast)
- `terrain.vert:124``L = max(dot(vWorldNormal, -sunDir), 0.08); vLightingRGB = sunCol * L + uCellAmbient.xyz`
- `mesh.frag:54-67``lit = uCellAmbient.xyz + Lcol * max(0, dot(N, -forward))`
- `sky.vert:87-91``lit = vec3(uEmissive) + uAmbientColor + uSunColor * max(dot(N, uSunDir), 0)`
Common bugs:
1. `uCellAmbient` / `uAmbientColor` are **pre-multiplied at load time** by
the keyframe's `AmbBright`. No dynamic per-frame scaling. Retail
re-computes `(ambBright + |sun|·scale) × ambColor` every frame.
2. `sunDir` is **always normalized** in
`SkyStateProvider.SunDirectionFromKeyframe` — loses the magnitude that
encodes sun intensity. In retail, `sunDir` with magnitude > 1 pushes
N·L above 1.0 pre-clamp; with magnitude < 1 it dims the directional
term globally (dusk).
3. `MIN_FACTOR = 0.08` is hard-coded in terrain.vert. Should be a
uniform sourced from retail's `DAT_00796344`.
## Port plan (minimum necessary)
### CPU side (SkyKeyframe struct)
Add three fields, **do not remove the pre-multiplied ones** (tests consume
them; preserve source compatibility):
```csharp
public readonly record struct SkyKeyframe(
// ... existing fields ...
Vector3 SunColor, // = DirColor * DirBright (kept for compat)
Vector3 AmbientColor, // = AmbColor * AmbBright (kept for compat)
// ── NEW for retail-accurate lighting ───────────────────────────
Vector3 DirColorRaw = default, // ColorToVec3(DirColor) — no bright mult
Vector3 AmbColorRaw = default, // ColorToVec3(AmbColor) — no bright mult
float DirBright = 1f, // DAT_00842780 is ambient scalar; rename accordingly
float AmbBright = 1f); // dat's AmbBright
// Sun-dir magnitude: keep heading/pitch unit-length. Retail's
// scale factor is small (_DAT_0079a1e8 looks like ~0.020.05 from
// context but I haven't decoded its exact value yet). Defer to
// later sprint unless it moves the needle.
```
### Shader side
Both `terrain.vert` and `mesh.frag` / `mesh_instanced.frag`:
```glsl
// Replace pre-baked uCellAmbient read with dynamic effective:
float ambBright = uCellAmbient.w /* or a new uniform */;
vec3 ambPremul = uCellAmbient.xyz * ambBright;
float L = max(dot(N, -uLights[0].dirAndRange.xyz), uAmbientFloor);
vec3 lit = uLights[0].colorAndIntensity.xyz * L + ambPremul;
```
But `uCellAmbient.w` is currently used for `active light count`, not
brightness. Two options:
- **Option A:** repurpose `uCellAmbient.w` as ambient brightness, move
active count to a new uniform / UBO field. Clean but invasive.
- **Option B:** Leave UBO layout alone; write the already-scaled ambient
into `uCellAmbient.xyz` at UBO-build time (same as today). Defer the
magnitude-encoding sunDir for a later sprint. This is the **minimum
change that matches user intent** — the ambient will now respond to
sun magnitude.
We're going with **Option B** — multiply `AmbientColor * (ambBright + |sunDir|·scale)`
at UBO build time, not at load time. Tests currently assume
`AmbientColor` is already pre-multiplied so we keep that semantic but
recompute per-frame instead of per-keyframe.
### CLAUDE.md fix
Line in the "Reference hierarchy by domain" section or wherever lighting
globals are documented:
- Swap "ambient from DAT_00842778, diffuse from DAT_0084277c" →
"**directional from DAT_00842778, ambient from DAT_0084277c**".
## Rollout order
1. Expose `AmbBright` scalar on `SkyKeyframe` + `AtmosphereSnapshot`
(load it, don't pre-multiply). Keep `AmbientColor` as the unscaled
vec3.
2. `SceneLightingUbo.Build` multiplies `AmbientColor * AmbBright` at
build time (per frame).
3. Run tests. `SkyDescLoaderTests`, `SkyStateProviderTests`,
`WeatherSystemTests` must all still pass.
4. Launch. Visual check: retail should now look indistinguishable for
overcast / rainy DayGroups. Sunny may be unchanged because
`AmbBright` is typically ~1.0 at noon.
5. If (4) still shows mismatch, investigate sunDir magnitude (Phase 2).
## Tests to add
- `SkyDescLoaderTests.ConvertTimeOfDay_ExposesAmbBrightScalar` — assert
that after load, `kf.AmbBright` matches the dat value and
`kf.AmbientColor` is NOT pre-multiplied (or that a new `AmbColorRaw`
field exists alongside).
- `SceneLightingUboTests.AmbientScalesWithAmbBright` — build two UBOs
with `AmbBright = 0.5` vs `AmbBright = 1.0`; assert `ubo.CellAmbient.xyz`
is half.
## Risks
- **Dim outdoor shading** if `AmbBright` is often < 0.5 in retail dats.
Mitigation: visual verify against retail screenshot. If too dim,
retail might apply a gamma/brightness offset elsewhere we haven't
spotted.
- **Breaks existing lighting tests** that pin `AmbientColor` magnitude.
Mitigation: update tests to check `AmbColorRaw * AmbBright` == old
value.

View file

@ -140,6 +140,10 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// from the server and schedules the dat-defined hooks (particle spawns,
// sounds, light toggles) at their StartTime offsets.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Remote-entity motion inference: tracks when each remote entity last
@ -827,6 +831,12 @@ public sealed class GameWindow : IDisposable
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
// resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline.
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(_dats, _particleSink);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
@ -1064,6 +1074,24 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted;
// Phase 6c — PlayScript (0xF754) arrives from the server as
// a (guid, scriptId) pair. Resolve the guid's current world
// position and feed the PhysicsScript runner; it schedules
// the script's hooks (particle spawns, sound cues, light
// toggles) at their StartTime offsets. This is the channel
// retail uses for spell casts, combat flinches, emote
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
// sound types (0x65..0x7B) play a one-shot audio cue.
// Lightning flashes arrive as a PAIRED PlayScript (the
// visual) + AdminEnvirons ThunderXSound (the audio) — both
// are handled here and in OnPlayScriptReceived respectively.
_liveSession.EnvironChanged += OnEnvironChanged;
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
@ -2222,6 +2250,93 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: teleport started (seq={sequence})");
}
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// with the CAMERA's current world position as the anchor. For
/// scene-wide storm effects (lightning) the camera is the right
/// reference frame since the flash is meant to be "around the
/// player." For per-entity effects the runner's dedupe by
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays
/// working on different guids.
///
/// <para>
/// Improvements for follow-up: look up the guid's actual last-
/// known world position from <c>_worldState</c> so per-entity
/// spell casts and emote gestures anchor correctly. For Phase 6
/// scope (lightning, which is Dereth-wide) the camera anchor is
/// sufficient.
/// </para>
/// </summary>
private void OnPlayScriptReceived(uint guid, uint scriptId)
{
if (_scriptRunner is null) return;
var camWorldPos = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
_scriptRunner.Play(scriptId, guid, camWorldPos);
}
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
/// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc)
/// for now — actual sound playback needs a lookup table from
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
/// have dat-indexed; follow-up will wire the thunder wave ids.
/// </summary>
private void OnEnvironChanged(uint environChangeType)
{
// Fog presets — values match AcDream.Core.World.EnvironOverride
// byte-for-byte (we deliberately mirrored retail's enum).
if (environChangeType <= 0x06u)
{
Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType;
Console.WriteLine(
$"live: AdminEnvirons fog override = " +
$"{(AcDream.Core.World.EnvironOverride)environChangeType}");
return;
}
// Sound cues 0x65..0x7B. Log by retail name for now; audio
// binding is a separate follow-up (needs sound-table indexing
// plus a PlaySound API on OpenAlAudioEngine that takes a
// retail sound enum → wave-id mapping).
string name = environChangeType switch
{
0x65u => "RoarSound",
0x66u => "BellSound",
0x67u => "Chant1Sound",
0x68u => "Chant2Sound",
0x69u => "DarkWhispers1Sound",
0x6Au => "DarkWhispers2Sound",
0x6Bu => "DarkLaughSound",
0x6Cu => "DarkWindSound",
0x6Du => "DarkSpeechSound",
0x6Eu => "DrumsSound",
0x6Fu => "GhostSpeakSound",
0x70u => "BreathingSound",
0x71u => "HowlSound",
0x72u => "LostSoulsSound",
0x75u => "SquealSound",
0x76u => "Thunder1Sound",
0x77u => "Thunder2Sound",
0x78u => "Thunder3Sound",
0x79u => "Thunder4Sound",
0x7Au => "Thunder5Sound",
0x7Bu => "Thunder6Sound",
_ => $"Unknown(0x{environChangeType:X2})",
};
Console.WriteLine(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
/// <summary>
/// Phase A.1: streaming load delegate, runs on the worker thread.
/// Reads the landblock from the dats, hydrates its stab entities (same
@ -3602,6 +3717,11 @@ public sealed class GameWindow : IDisposable
// Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated.
// Tick the PhysicsScript runner BEFORE the particle system so any
// CreateParticleHook fired this frame has its emitter alive when
// the particle system advances.
_scriptRunner?.Tick((float)deltaSeconds);
_particleSystem?.Tick((float)deltaSeconds);
int visibleLandblocks = 0;

View file

@ -1,29 +1,33 @@
#version 430 core
// Sky mesh fragment shader — UNLIT texture passthrough modulated by the
// per-keyframe SkyObjectReplace.Luminosity and .Transparent overrides.
// Sky mesh fragment shader — final composite matching retail's
// D3D fixed-function:
//
// fragment.rgb = texture.rgb * uLuminosity + lightning_flash
// fragment.a = texture.a * (1 - uTransparency)
// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash
// fragment.a = texture.a × (1 - uTransparency)
//
// uLuminosity defaults to 1.0 (no dim). A SkyObjectReplace entry with
// Luminosity_raw=11 (11%) sets uLuminosity to 0.11 — mesh renders at
// 11% brightness. MaxBright is min-clamped into uLuminosity by the C#
// renderer before it reaches the shader.
// uTransparency defaults to 0.0. Replace.Transparent_raw=100 (100%) sets
// uTransparency to 1.0 — alpha is zeroed and the pixel discarded
// (cloud hidden so the dome behind shows through).
// vTint arrives from the vertex shader with retail's per-vertex
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
// max(N·L, 0)) — see sky.vert for the decompile citation.
//
// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 + Phase 3b
// rationale in sky.vert.
// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override
// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the
// Surface.Luminosity that feeds uEmissive in the vertex shader — they
// compose multiplicatively in retail too.
//
// See `docs/research/2026-04-23-sky-material-state.md`.
in vec2 vTex;
in vec2 vTex;
in vec3 vTint;
in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far)
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency;
uniform float uLuminosity;
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1)
// Shared SceneLighting UBO — only fog-flash channel used (lightning).
// Shared SceneLighting UBO — fog params drive the mix, flash channel
// bumps sky brightness during lightning strikes. Matches sky.vert's
// declaration exactly.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -41,14 +45,38 @@ layout(std140, binding = 1) uniform SceneLighting {
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Unlit passthrough with per-keyframe dim.
vec3 rgb = sampled.rgb * uLuminosity;
// Composite: texture × per-vertex lit.
// `rep.Luminosity` is now pushed into `uEmissive` on the CPU side
// (SkyRenderer.cs) so `vTint` already saturates properly for bright
// keyframes. Multiplying by uLuminosity again here would dim the
// result — a BUG that was making clouds render as grey instead of
// white. Retail's fragment formula (FUN_0059da60 non-luminous
// branch) is texture × litColor × vertex.color(=white), so just
// `texture × vTint` is the retail-faithful composite.
vec3 rgb = sampled.rgb * vTint;
// Lightning additive bump (client-driven during storm keyframes).
// Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED
// 2026-04-24 — Dereth sky meshes are authored at radii 10501820m
// while the midnight keyframe's FogEnd is only 400m. Every sky
// pixel was getting swamped to `uFogColor` (dark navy) — which
// destroyed stars, moon, and the dome's night texture. Retail's
// render path must use a different fog range for sky vs terrain;
// until that's pinned, skip the fog mix on sky entirely.
// rgb = mix(uFogColor.rgb, rgb, vFogFactor);
// Lightning additive bump — client-driven during storm flashes.
// NOTE: the exact retail mechanism for lightning visual is still
// under research (agent #5, 2026-04-23). Keeping the uFogParams.z
// channel wired so if it ends up being a per-frame flash uniform
// that's what it becomes; if lightning turns out to be a particle
// system effect instead, this bump becomes a no-op (flash stays 0).
float flash = uFogParams.z;
rgb += flash * vec3(1.5, 1.5, 1.8);
float cap = mix(1.2, 3.0, clamp(flash, 0.0, 1.0));
// Normal-frame cap at 1.0 (retail D3D framebuffer clamps per-channel
// on output). Flash relaxes ceiling to 3.0 so storm strobes blow
// out visibly.
float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0));
rgb = min(rgb, vec3(cap));
float a = sampled.a * (1.0 - uTransparency);

View file

@ -1,26 +1,35 @@
#version 430 core
// Sky mesh vertex shader — UNLIT texture passthrough.
// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting
// ported to per-vertex GLSL. Evidence trail:
//
// Phase 2 experimented with per-vertex `emissive + ambient + diffuse×sun`
// lighting driven from the Surface.Luminosity field. The Phase 3a live
// verification (2026-04-23, user-observed against retail side-by-side
// at MorntideAndHalf) produced a "blue-green-yellow sweep" across the
// sky in acdream while retail showed a clean blue sky with white clouds.
// That's the signature of `diffuse × (250,215,151) warm-gold sunColor`
// tinting the cloud mesh's west-facing faces — retail does NOT do this.
// docs/research/2026-04-23-sky-material-state.md
// §Q2 — retail FUN_0059da60 writes D3DMATERIAL9 per-mesh:
// Material.Emissive.rgb = (Surface.Luminosity, Lum, Lum, 1)
// Material.Ambient/Diffuse from texture-modulate defaults
// §Q4 — D3DRS_LIGHTING is ON for sky meshes
// §Q6 — fragment formula:
// lit = Emissive
// + material.Ambient × light.Ambient
// + material.Diffuse × light.Diffuse × max(dot(N, -sun), 0)
//
// Retail sky meshes render UNLIT. The time-of-day color variation users
// observe (purple haze at night, warm dusk) comes from SkyObjectReplace
// per-keyframe Luminosity + Transparent modulation, revealing/dimming
// different mesh layers — NOT from per-vertex ambient multiply.
// Our `uAmbientColor` = retail's light.Ambient (AmbColor × AmbBright,
// pre-multiplied by SkyDescLoader). `uSunColor` = retail's light.Diffuse
// (DirColor × DirBright). `uSunDir` is a unit vector FROM surface TO
// sun (so `dot(N, uSunDir)` is the diffuse intensity directly; no
// extra negation needed — see SkyStateProvider.SunDirectionFromKeyframe).
// `uEmissive` is Surface.Luminosity for this submesh.
//
// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 for the
// surviving hypotheses and the Phase 3b decision rationale.
// Phase 2 (2026-04-23) tried the same formula and produced a visible
// east/west "blue-green-yellow sweep" — in hindsight that was CORRECT
// retail behaviour but paired with a wrong DayGroup pick ("Sunny" with
// sharp warm sun when retail rolled "Cloudy" with diffuse overcast).
// After Phase 3g fixed the LCG multiplier so acdream + retail agree on
// the DayGroup, the same formula should now match retail visually.
//
// Uniforms for Ambient/Sun/Emissive stay declared below so the C#-side
// plumbing doesn't need to change — they are simply UNUSED. A future
// phase can revive them if the decompile hunt proves retail applies
// lighting to sky through a different channel.
// NOTE: no clamp at the vertex — retail's D3D fixed-function lighting
// can produce lit values > 1.0 and the final clamp happens at the
// framebuffer write. Doing that same "let it overbright" here keeps
// the dome's emissive=1 saturation path intact.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
@ -31,16 +40,76 @@ uniform mat4 uSkyView;
uniform mat4 uSkyProjection;
uniform vec2 uUvScroll;
// Unused in Phase 3b — see header. Kept for forward-compat with the
// C# renderer's push calls.
uniform vec3 uAmbientColor;
uniform vec3 uSunColor;
uniform vec3 uSunDir;
// Per-frame lighting (from SkyKeyframe):
uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
uniform vec3 uSunDir; // unit vector FROM surface TO sun
// Per-submesh (from Surface.Luminosity float):
uniform float uEmissive;
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to
// compute the vertex fog factor. Must match sky.frag's declaration.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams; // x=fogStart, y=fogEnd, z=flash, w=fogMode
vec4 uFogColor;
vec4 uCameraAndTime;
};
out vec2 vTex;
out vec3 vTint;
out float vFogFactor; // 1 = no fog (close), 0 = full fog (far)
void main() {
vTex = aTex + uUvScroll;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
// uModel for sky is pure rotation (Z then Y) — orthonormal, so
// mat3(uModel) transforms normals correctly without inverse-transpose.
vec3 worldNormal = normalize(mat3(uModel) * aNormal);
// Retail per-vertex fixed-function lighting (AMBIENT=0 globally,
// so the global ambient term drops; only light.Ambient contributes).
// Clamp to [0,1] at the vertex — retail's D3DRS_COLORCLAMP defaults
// to clamping lit vertex colours to 1.0 BEFORE texture modulate.
// Without this, a dome vertex (uEmissive=1) picks up ambient+diff
// on top of already-saturated emissive, producing > 1.5 lit values
// that our framebuffer cap (1.2) lets through as 20% overbright
// vs retail's 1.0-clamped reference. User-observed 2026-04-23.
float diff = max(dot(worldNormal, uSunDir), 0.0);
vec3 lit = vec3(uEmissive) // material.Emissive
+ uAmbientColor // material.Ambient(1) × light.Ambient
+ uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L
vTint = clamp(lit, 0.0, 1.0);
// Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR,
// RANGEFOGENABLE=1, FOGTABLEMODE=NONE per device init — never
// toggled per frame). Distance = `|worldPos - cameraPos|`. Since
// our sky view matrix has translation zeroed (sky is camera-
// centered), the post-uModel position IS the camera-relative
// world-space vector, so its length is the 3D range distance.
// See docs/research/2026-04-23-sky-fog.md.
//
// Formula: fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
// 1.0 → no fog contribution (scene color wins)
// 0.0 → full fog color (sky color fades to fog)
//
// Sky meshes have intrinsic radii in the thousands of meters (dome
// / stars / moon are authored at large distances in the dat); at
// typical keyframe FOGEND=2400m, the dome saturates to fogColor at
// its horizon band. THAT is how retail colors the horizon at dusk.
vec4 worldPos = uModel * vec4(aPos, 1.0);
float dist = length(worldPos.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(fogEnd - fogStart, 1e-3);
vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0);
}

View file

@ -39,10 +39,19 @@ out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
// Retail's "ambient floor" constant from the decompiled AdjustPlanes
// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
// this fraction of the sun color — NOT additive with ambient.
const float MIN_FACTOR = 0.08;
// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
// chunk_00530000.c (AdjustPlanes). The decompile reads:
// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
// applied to the clamped Lambert result BEFORE it's multiplied into
// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
// but every other "floor" use in retail clamps negatives to zero (the
// physically-correct Lambert half-space). Our previous 0.08 was a
// defensive guess from early acdream days that made back-lit terrain
// visibly brighter than retail (user-observed 2026-04-24 "acdream
// warmer / less blue than retail"). Reverting to 0.0 matches retail
// per the decompile and lets ambient fill in the back side.
// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's

View file

@ -205,14 +205,18 @@ public sealed unsafe class SkyRenderer : IDisposable
else
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Per-submesh emissive (Surface.Luminosity FLOAT field —
// 1.0 for dome + sun + moon, 0.0 for clouds). The vertex
// shader saturates the lighting math when emissive=1.0 so
// self-illuminated meshes render at full texture brightness
// regardless of time of day; emissive=0.0 meshes get the
// full `ambient + diffuse × sun` tint (producing retail's
// purple night clouds / warm dusk clouds / pale noon clouds).
_shader.SetFloat("uEmissive", sub.SurfLuminosity);
// Emissive source: retail's FUN_0059da60 for non-luminous
// surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive
// (via material cache +0x3c). This PROMOTES bright-keyframe
// clouds into the self-lit term so the litColor saturates
// and the texture renders at full brightness rather than
// being dimmed by a per-fragment multiply.
//
// If no rep.Luminosity override: fall back to the Surface's
// static Luminosity (1.0 for dome/sun/moon → saturates;
// 0.0 for stars → stays ambient-lit, correct retail look).
float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity;
_shader.SetFloat("uEmissive", effEmissive);
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);

View file

@ -46,11 +46,51 @@ public sealed unsafe class TextureCache : IDisposable
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
/// alpha = 1.0 everywhere we know the decode path strips the alpha
/// channel somewhere. Printed once per unique surfaceId under
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
/// </summary>
private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded)
{
if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0)
{
System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty");
return;
}
int total = decoded.Rgba8.Length / 4;
// Bucket alpha in 10 bins.
var buckets = new int[10];
int aMin = 255, aMax = 0;
long aSum = 0;
for (int i = 0; i < decoded.Rgba8.Length; i += 4)
{
int a = decoded.Rgba8[i + 3];
if (a < aMin) aMin = a;
if (a > aMax) aMax = a;
aSum += a;
int b = a * 10 / 256;
if (b > 9) b = 9;
buckets[b]++;
}
float aMean = aSum / (float)total / 255f;
var pct = new string[10];
for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%";
System.Console.WriteLine(
$"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " +
$"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " +
$"bins[0-9]={string.Join(",", pct)}");
}
/// <summary>
/// Get or upload a texture for a Surface id but with its
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.

View file

@ -109,6 +109,56 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
/// wire format <c>[u32 opcode][u32 guid][u32 scriptId]</c>.
///
/// <para>
/// This is retail's ONLY general-purpose "make a visual thing
/// happen" channel: spell casts, emote gestures, combat flinches,
/// portal storms, and lightning flashes during stormy weather all
/// flow through this opcode. Subscribers (typically
/// <c>GameWindow</c>) resolve the guid to the appropriate entity
/// position and dispatch to a <c>PhysicsScriptRunner</c>.
/// </para>
///
/// <para>
/// Trail: <c>chunk_006A0000.c:12320-12336</c> opcode dispatch →
/// <c>FUN_00452060</c> → <c>FUN_00511800</c> → <c>FUN_005117a0</c>
/// (PhysicsObj::RunScript) → <c>FUN_0051bed0</c> (PhysicsScript
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
/// </para>
/// </summary>
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
/// <summary>
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
/// <c>0xEA60</c>) — the one-and-only channel retail's server uses
/// for weather environment changes. Wire format:
/// <c>[u32 opcode][u32 environChangeType]</c>. The payload enum is
/// retail's <c>EnvironChangeType</c>:
/// <list type="bullet">
/// <item><description>
/// <c>0x00..0x06</c> — fog presets (Clear/Red/Blue/White/Green/
/// Black/Black2). Subscribers route these to a
/// <see cref="AcDream.Core.World.WeatherSystem.Override"/>.
/// </description></item>
/// <item><description>
/// <c>0x65..0x75</c> — one-shot ambient sound cues
/// (Roar / Bell / Chant / etc).
/// </description></item>
/// <item><description>
/// <c>0x76..0x7B</c> — Thunder1..Thunder6 sounds. Paired with
/// a separate <see cref="PlayScriptReceived"/> from the server
/// carrying the lightning-flash PhysicsScript.
/// </description></item>
/// </list>
/// See <c>docs/research/2026-04-23-lightning-crossfade.md</c> +
/// <c>2026-04-23-lightning-real.md</c>.
/// </summary>
public event Action<uint /*environChangeType*/>? EnvironChanged;
/// <summary>
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
@ -548,6 +598,35 @@ public sealed class WorldSession : IDisposable
var env = GameEventEnvelope.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value);
}
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
{
// Phase 5d: wire format `[u32 opcode][u32 environChangeType]`
// per chunk_006A0000.c. Dispatch the event; GameWindow
// subscribers route fog presets into WeatherSystem.Override
// and sound cues (thunder, roar, etc) into the audio engine.
if (body.Length >= 8)
{
uint envType = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
EnvironChanged?.Invoke(envType);
}
}
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
{
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
// per chunk_006A0000.c:12320 disassembly. Dispatch the
// event; GameWindow subscribes and feeds its
// PhysicsScriptRunner. This is the channel retail uses for
// lightning flashes, spell casts, emotes, combat FX, etc.
if (body.Length >= 12)
{
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
uint scriptId = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
PlayScriptReceived?.Invoke(targetGuid, scriptId);
}
}
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the

View file

@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.Types;
// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in
// VfxModel.cs; alias the dat-reader type to avoid name collision.
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Vfx;
/// <summary>
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime —
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script
/// start). Every visible effect the server triggers via the
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
/// spell casts, emote gestures, combat flinches, AND — per the
/// 2026-04-23 lightning research — weather lightning flashes.
///
/// <para>
/// Decompile provenance (see
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
/// <list type="bullet">
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
/// public API: resolves the dat id, allocates a script node, inserts
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
/// </description></item>
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
/// </description></item>
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
/// schedules the next fire time based on the next hook's
/// <c>StartTime</c>.
/// </description></item>
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
/// <c>head.NextHookAbsTime &lt;= globalClock</c>, fire hooks via
/// vtable dispatch on the owner <c>PhysicsObj</c>.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// <b>Design choices vs retail:</b>
/// <list type="bullet">
/// <item><description>Flat list, not a linked list — iteration is
/// simpler and N is small (&lt; 100 active scripts in practice).
/// </description></item>
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
/// — same pair re-played replaces the old instance so we don't
/// stack duplicates when the server retriggers.
/// </description></item>
/// <item><description>The anchor world position is cached at spawn
/// time. For long-running scripts on moving entities, the caller
/// can <see cref="Play"/> again with a fresh position each
/// frame — idempotent.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class PhysicsScriptRunner
{
private readonly Func<uint, DatPhysicsScript?> _resolver;
private readonly IAnimationHookSink _sink;
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
// One active node per (scriptId, entityId) pair. Replaying replaces.
private readonly List<ActiveScript> _active = new();
private double _now; // absolute runtime in seconds
/// <summary>
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment,
/// every <see cref="Play"/> call and every hook fire prints a line
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
/// and which script IDs those are. Off by default.
/// </summary>
public bool DiagEnabled { get; set; } =
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <summary>
/// Preferred ctor — resolver delegate lets this class stay
/// DatCollection-free for testing. Production code will pass
/// a lambda that hits <c>DatCollection.Get&lt;PhysicsScript&gt;</c>.
/// </summary>
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
}
/// <summary>
/// Convenience ctor — builds a resolver around a <see cref="DatCollection"/>.
/// </summary>
public PhysicsScriptRunner(DatCollection dats, IAnimationHookSink sink)
: this(id => SafeGet(dats, id), sink)
{
}
private static DatPhysicsScript? SafeGet(DatCollection dats, uint id)
{
if (dats is null) return null;
try { return dats.Get<DatPhysicsScript>(id); }
catch { return null; }
}
/// <summary>Number of scripts currently active (for telemetry).</summary>
public int ActiveScriptCount => _active.Count;
/// <summary>
/// Start (or restart) a PhysicsScript on the given entity.
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
/// <c>true</c> if the script was found and queued, <c>false</c>
/// if the dat lookup failed. Replaying the same
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
/// instead of stacking.
/// </summary>
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
{
if (scriptId == 0) return false;
var script = ResolveScript(scriptId);
if (script is null || script.ScriptData.Count == 0)
{
if (DiagEnabled)
Console.WriteLine($"[pes] Play: script 0x{scriptId:X8} not found / empty");
return false;
}
// Dedupe: if this (scriptId, entityId) already has an active
// instance, replace it — retail's ScriptManager doesn't
// double-schedule the same script on the same object in the
// common path.
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
_active.RemoveAt(i);
}
_active.Add(new ActiveScript
{
Script = script,
ScriptId = scriptId,
EntityId = entityId,
AnchorWorld = anchorWorldPos,
StartTimeAbs = _now,
NextHookIndex = 0,
});
if (DiagEnabled)
{
Console.WriteLine(
$"[pes] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " +
$"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " +
$"hooks={script.ScriptData.Count}");
}
return true;
}
/// <summary>
/// Advance every active script by <paramref name="dtSeconds"/>.
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
/// (measured from the script's <see cref="Play"/> moment) has been
/// reached. Removes scripts that have finished all their hooks.
/// </summary>
public void Tick(float dtSeconds)
{
if (dtSeconds < 0) dtSeconds = 0;
_now += dtSeconds;
// Back-to-front so RemoveAt() is cheap and safe mid-iteration.
for (int i = _active.Count - 1; i >= 0; i--)
{
var a = _active[i];
double elapsed = _now - a.StartTimeAbs;
// Fire every hook whose scheduled time has arrived.
while (a.NextHookIndex < a.Script.ScriptData.Count
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed)
{
var entry = a.Script.ScriptData[a.NextHookIndex];
DispatchHook(a, entry.Hook);
a.NextHookIndex++;
}
if (a.NextHookIndex >= a.Script.ScriptData.Count)
_active.RemoveAt(i);
else
_active[i] = a;
}
}
/// <summary>
/// Stop an active script instance by
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
/// despawns. Not necessary to call on normal script completion —
/// scripts self-remove via <see cref="Tick"/>.
/// </summary>
public void Stop(uint scriptId, uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
public void StopAllForEntity(uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
private void DispatchHook(ActiveScript a, AnimationHook hook)
{
if (DiagEnabled)
{
Console.WriteLine(
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
$"hook={hook.HookType}");
}
// Handle the nested-script hook inline — it needs our runner.
// Everything else delegates to the sink (ParticleHookSink
// handles CreateParticle, DestroyParticle, StopParticle,
// CreateBlockingParticle, etc).
if (hook is CallPESHook call)
{
// CallPESHook.PES = sub-script id; Pause = delay before the
// sub-script starts (retail's ScriptManager links it into
// the list with StartTime = now + Pause). For our flat-list
// design we just recurse Play() — the sub-script schedules
// its own hooks from its own time zero. If Pause > 0 we
// delay by baking it into the sub-script's StartTimeAbs.
Play(call.PES, a.EntityId, a.AnchorWorld);
if (call.Pause > 0f && _active.Count > 0)
{
var sub = _active[^1];
sub.StartTimeAbs = _now + call.Pause;
_active[^1] = sub;
}
return;
}
_sink.OnHook(a.EntityId, a.AnchorWorld, hook);
}
private DatPhysicsScript? ResolveScript(uint id)
{
if (_scriptCache.TryGetValue(id, out var cached)) return cached;
var script = _resolver(id);
_scriptCache[id] = script;
return script;
}
/// <summary>
/// Test-only seam: pre-seed the resolver cache with a hand-built
/// script so unit tests can exercise the scheduler without loading
/// dats. Production code never calls this (name carries the warning).
/// </summary>
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
=> _scriptCache[id] = script;
private struct ActiveScript
{
public DatPhysicsScript Script;
public uint ScriptId;
public uint EntityId;
public Vector3 AnchorWorld;
public double StartTimeAbs;
public int NextHookIndex;
}
}

View file

@ -96,47 +96,42 @@ public readonly record struct AtmosphereSnapshot(
/// </summary>
public sealed class WeatherSystem
{
/// <summary>
/// Kept as a public constant because a handful of callers / tests
/// reference it, but unused internally post-Phase-7: retail does
/// not cross-fade between <see cref="WeatherKind"/>s (no such
/// concept in the decompile). The SkyTimeOfDay keyframe interp
/// does all time-based fog/light blending directly.
/// </summary>
public const float TransitionSeconds = 10f;
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
// and decays exponentially with a time constant of ~200ms.
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
// Flash decay kept so TriggerFlash() is still a usable test hook;
// production code (PlayScript-driven lightning, Phase 6) does NOT
// drive the flash uniform — it spawns particle emitters directly.
private const float FlashDecay = 1f / 0.200f; // 1 / τ sec
private const float FlashPeakHoldS = 0.05f;
// Retail storm cadence: 830 seconds between strikes.
private const float StrikeIntervalMinS = 8f;
private const float StrikeIntervalMaxS = 30f;
// Overcast-kind fog feels like ~40150m retail range (r12 §5.1).
private const float OvercastFogStart = 40f;
private const float OvercastFogEnd = 150f;
private const float StormFogStart = 25f;
private const float StormFogEnd = 90f;
private WeatherKind _kind = WeatherKind.Clear;
private WeatherKind _kind = WeatherKind.Clear;
private WeatherKind _previousKind = WeatherKind.Clear;
private float _transitionT; // 0..1 through the cross-fade
private float _flashLevel;
private float _flashAge; // seconds since last strike
private float _nextStrikeInS;
private float _flashAge;
private EnvironOverride _override;
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
private int _rolledDayIndex = int.MinValue;
// Phase 3e — when GameWindow (via RefreshSkyForCurrentDay) pushes
// the active retail DayGroup name through SetKindFromDayGroupName,
// the internal RollKind hash becomes unused. This flag stops Tick's
// auto-roll so external control can't fight the internal one.
// Phase 3e — when GameWindow pushes the retail DayGroup name via
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
private bool _externallyDriven;
private readonly Random _strikeJitter;
public WeatherSystem(Random? rng = null)
{
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
_nextStrikeInS = 12f;
// The random-seed ctor argument remains for test API compat,
// but no longer drives any production behaviour (Phase 7: the
// Storm-kind random lightning timer was deleted — retail is
// server-driven via PlayScript; see Agents #3 and #5).
_ = rng;
}
/// <summary>Current active weather.</summary>
@ -189,11 +184,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;
@ -208,15 +227,19 @@ public sealed class WeatherSystem
/// </summary>
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
{
// Cross-fade progression: transitionT advances toward 1 over
// TransitionSeconds. Capped; no further rollover.
if (_transitionT < 1f)
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
// Phase 7 — dropped:
// - per-Kind cross-fade (_transitionT drove the now-removed
// FogForKind lerp; retail has no such machinery).
// - Storm-kind random lightning timer (retail lightning is
// server-driven via PlayScript per Agent #5 — purely visual
// through the particle system, no UBO flash channel).
//
// What remains: day-index auto-roll as a TEST-ONLY fallback
// (externally driven callers set _externallyDriven=true through
// SetKindFromDayGroupName and this block never fires), plus
// flash-level decay so the TriggerFlash() test hook still works.
// Day changed → re-roll. Skip the sentinel (forced). Also skip
// when weather is externally driven by the retail DayGroup name
// (Phase 3e) — the internal RollKind is a fallback only for
// tests / offline code paths.
// Day changed → re-roll (fallback only — disabled when externally driven).
if (!_externallyDriven
&& dayIndex != _rolledDayIndex
&& _rolledDayIndex != int.MaxValue)
@ -226,19 +249,9 @@ public sealed class WeatherSystem
if (newKind != _kind) BeginTransition(newKind);
}
// Lightning timer only ticks in Storm kind.
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
{
_nextStrikeInS -= dtSeconds;
if (_nextStrikeInS <= 0f)
{
TriggerFlash();
_nextStrikeInS = StrikeIntervalMinS
+ (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
}
}
// Decay the flash level with a 200ms time constant.
// Flash decay — 50ms hold then exponential decay (~200ms τ).
// Production never TriggerFlashes; this exists for tests that
// exercise the UBO channel.
if (_flashLevel > 0f)
{
_flashAge += dtSeconds;
@ -260,40 +273,45 @@ public sealed class WeatherSystem
}
/// <summary>
/// Produce the per-frame snapshot consumed by the shader UBO +
/// particle emitter spawners. Combines the sky keyframe's fog with
/// the weather state's fog overlay, then applies the server
/// <see cref="EnvironOverride"/> tint if any.
/// Produce the per-frame atmosphere snapshot from the sky keyframe.
///
/// <para>
/// <b>Retail-faithful since Phase 7 (2026-04-23):</b> fog is the
/// keyframe's fog, passed through directly (color + distances).
/// The only override channel is <see cref="EnvironOverride"/> set
/// by the server's <c>AdminEnvirons</c> packet (opcode 0xEA60) —
/// in that case we substitute the fog COLOR with the preset tint
/// and keep the keyframe's distances untouched. There is no
/// per-<see cref="WeatherKind"/> fog manipulation: retail's
/// decompile (Agent #3, 2026-04-23) contains no such logic. The
/// <see cref="WeatherKind"/> enum is now purely informational — it
/// labels the current sky style for debug overlays but doesn't
/// drive any rendering.
/// </para>
/// </summary>
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
{
// Cross-fade fog distance + color from previous-kind to new-kind.
var prev = FogForKind(_previousKind, kf);
var curr = FogForKind(_kind, kf);
// Fog passthrough from the keyframe (retail semantics).
Vector3 fogColor = kf.FogColor;
float fogStart = kf.FogStart;
float fogEnd = kf.FogEnd;
float t = _transitionT;
var fogColor = Vector3.Lerp(prev.color, curr.color, t);
float fogStart = prev.start + (curr.start - prev.start) * t;
float fogEnd = prev.end + (curr.end - prev.end) * t;
// Server environ override wins.
// AdminEnvirons server override: replace fog COLOR only.
// Keyframe distances unchanged until we find evidence retail
// changes those too (Agent #3 notes the in-game crossfade
// lerps distances via SkyTimeOfDay keyframe interp, NOT via
// AdminEnvirons directly).
if (_override != EnvironOverride.None)
{
fogColor = EnvironOverrideColor(_override);
fogStart = 15f;
fogEnd = 80f; // Dense override fog
}
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
return new AtmosphereSnapshot(
Kind: _kind,
Intensity: Math.Clamp(intensity, 0f, 1f),
Kind: _kind, // informational
Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
FogMode: kf.FogMode,
LightningFlash: _flashLevel,
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
Override: _override);
}
@ -305,7 +323,6 @@ public sealed class WeatherSystem
{
_previousKind = _kind;
_kind = newKind;
_transitionT = 0f;
}
/// <summary>
@ -330,23 +347,6 @@ public sealed class WeatherSystem
return WeatherKind.Storm;
}
private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
{
return kind switch
{
WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
OvercastFogStart, OvercastFogEnd),
WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
OvercastFogStart, OvercastFogEnd),
WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
OvercastFogStart, OvercastFogEnd * 1.2f),
WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
StormFogStart, StormFogEnd),
_ => (kf.FogColor, kf.FogStart, kf.FogEnd),
};
}
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
{
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),

View file

@ -0,0 +1,210 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptRunnerTests
{
/// <summary>
/// Recording sink so tests can assert each hook dispatch.
/// </summary>
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
}
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
{
var script = new DatPhysicsScript();
foreach (var (t, h) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
return script;
}
private static CreateParticleHook CreateHook(uint emitterInfoId)
=> new CreateParticleHook { EmitterInfoId = emitterInfoId };
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts)
{
// Build an in-memory resolver from the script table — no DatCollection needed.
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
return new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
sink);
}
[Fact]
public void Play_UnknownScript_ReturnsFalse()
{
var sink = new RecordingSink();
var runner = MakeRunner(sink); // no scripts registered
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
Assert.Empty(sink.Calls);
}
[Fact]
public void Play_ZeroScriptId_IgnoredSilently()
{
var sink = new RecordingSink();
var runner = MakeRunner(sink);
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void HooksFire_InOrder_AtScheduledTimes()
{
var script = BuildScript(
(0.0, CreateHook(100)),
(0.5, CreateHook(101)),
(1.0, CreateHook(102)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
runner.Tick(0.25f);
Assert.Single(sink.Calls);
Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
runner.Tick(0.35f); // total 0.6
Assert.Equal(2, sink.Calls.Count);
Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
runner.Tick(0.9f); // total 1.5
Assert.Equal(3, sink.Calls.Count);
Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId);
Assert.Equal(0, runner.ActiveScriptCount); // fully consumed
}
[Fact]
public void EntityIdAndAnchor_ArePassedThrough()
{
var script = BuildScript((0.0, CreateHook(1)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
var anchor = new Vector3(123, 45, 67);
runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor);
runner.Tick(0.1f);
Assert.Single(sink.Calls);
Assert.Equal(0xCAFEu, sink.Calls[0].EntityId);
Assert.Equal(anchor, sink.Calls[0].Pos);
}
[Fact]
public void Replay_SameScriptSameEntity_Replaces_DoesNotStack()
{
var script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
runner.Tick(0.1f);
Assert.Single(sink.Calls);
// Re-play — the old instance should be replaced, not stacked.
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(0.1f);
Assert.Equal(2, sink.Calls.Count);
// Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1.
Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
}
[Fact]
public void Replay_DifferentEntities_BothActiveConcurrently()
{
var script = BuildScript((0.0, CreateHook(42)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0));
runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0));
Assert.Equal(2, runner.ActiveScriptCount);
runner.Tick(0.1f);
Assert.Equal(2, sink.Calls.Count);
Assert.Contains(sink.Calls, c => c.EntityId == 1u);
Assert.Contains(sink.Calls, c => c.EntityId == 2u);
}
[Fact]
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers()
{
var script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero);
runner.Tick(0.1f); // both fire hook 0
Assert.Equal(2, sink.Calls.Count);
runner.StopAllForEntity(1);
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(2.0f); // only entity 2's script should fire hook 1
Assert.Equal(3, sink.Calls.Count);
Assert.Equal(2u, sink.Calls[^1].EntityId);
}
[Fact]
public void CallPES_NestedScript_SpawnsOnSameEntity()
{
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f }));
var inner = BuildScript((0.0, CreateHook(99)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
// First tick fires the CallPES hook. Inner script gets queued to
// _active but does NOT fire this tick (we iterate _active
// backwards, and the inner is appended AFTER the current index) —
// matches retail's linked-list insertion semantics. Inner fires
// on the NEXT tick instead.
runner.Tick(0.1f);
Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit
Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done
// Second tick — inner's hook at t=0 fires now.
runner.Tick(0.1f);
Assert.Single(sink.Calls);
Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
Assert.Equal(0x7u, sink.Calls[0].EntityId);
}
[Fact]
public void CallPES_WithPause_DelaysSubScript()
{
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f }));
var inner = BuildScript((0.0, CreateHook(99)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
// CallPES fires immediately, but inner script's hook is gated by Pause.
runner.Tick(0.1f);
Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s
runner.Tick(0.5f); // total 0.6 > 0.5 pause
Assert.Single(sink.Calls);
}
}

View file

@ -38,19 +38,30 @@ public sealed class WeatherSystemTests
}
[Fact]
public void Transition_EasesAcrossTenSeconds()
public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind()
{
// Force Storm, then Clear, sample snapshot fog distance mid-transition.
var sys = new WeatherSystem();
sys.ForceWeather(WeatherKind.Storm);
sys.Tick(0, 1, 100f); // finalize
// Phase 7: retail DOES NOT override fog by WeatherKind — Storm
// doesn't produce denser fog, Overcast doesn't shrink distance.
// Every Kind renders the keyframe's fog directly. This test
// replaces the old "Transition_EasesAcrossTenSeconds" which
// codified the invented per-Kind fog behaviour.
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var stormFog = sys.Snapshot(in kf);
Assert.Equal(WeatherKind.Storm, stormFog.Kind);
// Snapshot should have a small fog end (storm fog is dense).
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
foreach (var kind in new[] {
WeatherKind.Clear, WeatherKind.Overcast,
WeatherKind.Rain, WeatherKind.Snow, WeatherKind.Storm,
})
{
var sys = new WeatherSystem();
sys.ForceWeather(kind);
sys.Tick(0, 1, 100f); // finalize any transition
var snap = sys.Snapshot(in kf);
Assert.Equal(kind, snap.Kind);
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
Assert.Equal(kf.FogColor, snap.FogColor);
}
}
[Fact]
@ -101,21 +112,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();

View file

@ -1,6 +1,6 @@
// RetailTimeProbe — read the live retail acclient.exe process memory and
// dump its TimeOfDay struct so we can compare against acdream's computed
// calendar values.
// dump its TimeOfDay struct + sky-lighting global block so we can compare
// against acdream's computed calendar / SkyKeyframe values.
//
// Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md
// §4 and the daygroup-selection research):
@ -18,6 +18,30 @@
// TimeOfDay +0x68 int — DayOfYear
// TimeOfDay +0x6C int — SeasonIndex
//
// Sky-lighting globals (hunt-C §1, with 2026-04-24 label correction — the
// DirColor/AmbColor labeling in §1/§2/§5 was backwards; we use the
// corrected mapping):
//
// DAT_00842778 4 ARGB DirColor (directional / sun color)
// DAT_0084277c 4 ARGB AmbColor (ambient color)
// DAT_00842780 4 float AmbBright (ambient brightness scalar, also fog-start offset)
// DAT_00842784 4 ARGB FogSecondary
// DAT_00842788 4 ARGB FogPrimary
// DAT_00842950 12 3×flt sunDir XYZ (|v| = DirBright, NOT a unit vector)
// DAT_0084295c 4 float DirBright floor (MinWorldFog clamp)
// DAT_0079a1e8 4 float fog-distance scale factor (used in
// fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright)
//
// Cached D3D light struct (written by FUN_00505f30:6058-6065 and
// FUN_004530e0:2083-2086 — see chunk_00500000.c / chunk_00450000.c):
//
// DAT_008682b0 12 3×flt light.Ambient pre-mul = fogTint * AmbBright
// (set inside FUN_004530e0 via FUN_00451a60(DirColor))
// DAT_008682bc 12 3×flt sunDir copy (fVar1/2/3 = X/Y/Z)
// DAT_008682c8 12 3×flt sunDir primary
// DAT_008682d4 4 uint reserved (written 0)
// DAT_008682d8 4 uint light type (3 = directional)
//
// The acclient.exe referenced in the decompile has preferred image base
// 0x00400000 (standard Win32 default). If ASLR is enabled the actual
// load address will differ — we compute relative to Process.MainModule
@ -48,6 +72,27 @@ internal static class Program
private const int Off_DayOfYear = 0x68; // int
private const int Off_SeasonIndex = 0x6C; // int
// Sky-lighting globals (static VAs in acclient.exe image).
private const uint SkyBlockBase = 0x00842778u; // DirColor / start of sky block
private const uint SkyBlockSize = 72u; // 0x00842778..0x008427c0 = 72 bytes
private const uint DAT_DirColor = 0x00842778u; // ARGB
private const uint DAT_AmbColor = 0x0084277cu; // ARGB
private const uint DAT_AmbBright = 0x00842780u; // float
private const uint DAT_FogSecondary = 0x00842784u; // ARGB
private const uint DAT_FogPrimary = 0x00842788u; // ARGB
private const uint DAT_SunDirX = 0x00842950u; // float
private const uint DAT_SunDirY = 0x00842954u; // float
private const uint DAT_SunDirZ = 0x00842958u; // float
private const uint DAT_DirBrightMin = 0x0084295cu; // float (MinWorldFog / DirBright floor)
private const uint DAT_FogScale = 0x0079a1e8u; // float (|sun|·scale factor)
// Cached D3D light struct.
private const uint DAT_LightAmbient = 0x008682b0u; // 3×float (light.Ambient pre-mul)
private const uint DAT_LightDirCopy = 0x008682bcu; // 3×float (sunDir copy)
private const uint DAT_LightDirMain = 0x008682c8u; // 3×float (sunDir primary)
private const uint DAT_LightReserved = 0x008682d4u; // uint
private const uint DAT_LightType = 0x008682d8u; // uint (3 = directional)
// Process access rights needed: read memory + query info.
private const uint PROCESS_VM_READ = 0x0010u;
private const uint PROCESS_QUERY_INFORMATION = 0x0400u;
@ -55,22 +100,51 @@ internal static class Program
private static int Main(string[] args)
{
// Retail's process name is "acclient" (.exe stripped by Process API).
// Allow override from the command line just in case.
string processName = args.Length > 0 ? args[0] : "acclient";
Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"...");
Process[] procs = Process.GetProcessesByName(processName);
if (procs.Length == 0)
// args[0] = process name OR "pid=NNNN" to target a specific pid.
string processName = "acclient";
int? requestedPid = null;
foreach (var a in args)
{
Console.Error.WriteLine(
$"no process named \"{processName}\" is running. Launch the retail AC client " +
"and log in to a character first, then re-run this probe.");
return 2;
if (a.StartsWith("pid=", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(a.Substring(4), out var pidParsed))
requestedPid = pidParsed;
else
processName = a;
}
if (procs.Length > 1)
Console.WriteLine($"(found {procs.Length} matching processes — probing the first)");
Process target = procs[0];
Process target;
if (requestedPid is int pid)
{
try { target = Process.GetProcessById(pid); }
catch (Exception ex)
{
Console.Error.WriteLine($"no process with pid={pid}: {ex.Message}");
return 2;
}
Console.WriteLine($"RetailTimeProbe — targeting pid={pid} ({target.ProcessName})");
}
else
{
Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"...");
Process[] procs = Process.GetProcessesByName(processName);
if (procs.Length == 0)
{
Console.Error.WriteLine(
$"no process named \"{processName}\" is running. Launch the retail AC client " +
"and log in to a character first, then re-run this probe.");
return 2;
}
if (procs.Length > 1)
{
Console.WriteLine($"(found {procs.Length} matching processes — use `pid=NNNN` to target a specific one)");
foreach (var p in procs)
{
Console.WriteLine($" pid={p.Id} start={p.StartTime:HH:mm:ss} title=\"{p.MainWindowTitle}\"");
}
Console.WriteLine("(probing the first)");
}
target = procs[0];
}
Console.WriteLine(
$"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " +
$"mainmodule={target.MainModule?.FileName ?? "<null>"}");
@ -155,6 +229,9 @@ internal static class Program
double inferredTick = curDayStart + dayFraction * (curDayEnd - curDayStart);
Console.WriteLine($" inferred retail tick = {inferredTick:F3}");
Console.WriteLine($" retail LCG seed = year*secsPerDay + dayOfYear = {year}*{secsPerDayI}+{dayOfYear} = {(long)year * secsPerDayI + dayOfYear}");
// ---------------- Sky-lighting block dump ----------------
DumpSkyBlock(handle, moduleBase);
return 0;
}
finally
@ -163,6 +240,103 @@ internal static class Program
}
}
private static void DumpSkyBlock(IntPtr handle, IntPtr moduleBase)
{
// Helper to relocate a preferred-image-base VA onto the live module.
IntPtr Reloc(uint va) =>
(IntPtr)(moduleBase.ToInt64() + (long)(va - PreferredImageBase));
Console.WriteLine();
Console.WriteLine("=========== Sky globals (retail acclient.exe, live) ===========");
// Raw block dump for the contiguous 72-byte region at 0x00842778.
byte[] block = ReadBytes(handle, Reloc(SkyBlockBase), (int)SkyBlockSize);
Console.Write($" [raw {SkyBlockBase:X8}..{SkyBlockBase + SkyBlockSize - 1:X8}]");
for (int i = 0; i < block.Length; i++)
{
if ((i % 16) == 0) Console.Write($"\n +{i:X2}:");
Console.Write($" {block[i]:X2}");
}
Console.WriteLine();
Console.WriteLine();
// Primary field-by-field decode.
uint dirColor = ReadUInt32(handle, Reloc(DAT_DirColor));
uint ambColor = ReadUInt32(handle, Reloc(DAT_AmbColor));
float ambBright = ReadSingle(handle, Reloc(DAT_AmbBright));
uint fogSecondary = ReadUInt32(handle, Reloc(DAT_FogSecondary));
uint fogPrimary = ReadUInt32(handle, Reloc(DAT_FogPrimary));
float sunX = ReadSingle(handle, Reloc(DAT_SunDirX));
float sunY = ReadSingle(handle, Reloc(DAT_SunDirY));
float sunZ = ReadSingle(handle, Reloc(DAT_SunDirZ));
float dirBrightMin = ReadSingle(handle, Reloc(DAT_DirBrightMin));
float fogScale = ReadSingle(handle, Reloc(DAT_FogScale));
double dirBright = Math.Sqrt((double)sunX * sunX + (double)sunY * sunY + (double)sunZ * sunZ);
Console.WriteLine($" [retail sky] DirColor = {FormatArgb(dirColor)}");
Console.WriteLine($" [retail sky] AmbColor = {FormatArgb(ambColor)}");
Console.WriteLine($" [retail sky] AmbBright = {ambBright:F4} (@0x{DAT_AmbBright:X8})");
Console.WriteLine($" [retail sky] FogPrimary = {FormatArgb(fogPrimary)} (@0x{DAT_FogPrimary:X8})");
Console.WriteLine($" [retail sky] FogSecondary = {FormatArgb(fogSecondary)} (@0x{DAT_FogSecondary:X8})");
Console.WriteLine($" [retail sky] sunDir = ({sunX,7:F4},{sunY,7:F4},{sunZ,7:F4}) |dir|=DirBright={dirBright:F4}");
Console.WriteLine($" [retail sky] DirBrightMin = {dirBrightMin:F4} (@0x{DAT_DirBrightMin:X8}, MinWorldFog clamp)");
Console.WriteLine($" [retail sky] 0x0079a1e8 = {fogScale:F6} (fog |sun|-scale factor)");
// Derived fog distance (matches FUN_00505f30:6067-6069):
// fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright
double fogDist = dirBright * fogScale + ambBright;
Console.WriteLine($" [retail sky] derived fogDist = |sun|*scale + AmbBright = {fogDist:F4}");
// ---- Cached D3D light struct at 0x008682b0..0x008682d8 (40 bytes) ----
Console.WriteLine();
Console.WriteLine(" -- cached D3D light struct (0x008682b0..0x008682d8) --");
float ambR = ReadSingle(handle, Reloc(DAT_LightAmbient + 0));
float ambG = ReadSingle(handle, Reloc(DAT_LightAmbient + 4));
float ambB = ReadSingle(handle, Reloc(DAT_LightAmbient + 8));
float dcX = ReadSingle(handle, Reloc(DAT_LightDirCopy + 0));
float dcY = ReadSingle(handle, Reloc(DAT_LightDirCopy + 4));
float dcZ = ReadSingle(handle, Reloc(DAT_LightDirCopy + 8));
float dmX = ReadSingle(handle, Reloc(DAT_LightDirMain + 0));
float dmY = ReadSingle(handle, Reloc(DAT_LightDirMain + 4));
float dmZ = ReadSingle(handle, Reloc(DAT_LightDirMain + 8));
uint reservedVal = ReadUInt32(handle, Reloc(DAT_LightReserved));
uint lightType = ReadUInt32(handle, Reloc(DAT_LightType));
Console.WriteLine($" [retail sky] cache.amb = ({ambR,7:F4},{ambG,7:F4},{ambB,7:F4}) (fogTint * AmbBright, effective light.Ambient)");
Console.WriteLine($" [retail sky] cache.dirCpy = ({dcX,7:F4},{dcY,7:F4},{dcZ,7:F4}) (008682bc/c0/c4, sunDir duplicate)");
Console.WriteLine($" [retail sky] cache.dirMain= ({dmX,7:F4},{dmY,7:F4},{dmZ,7:F4}) (008682c8/cc/d0, sunDir primary)");
Console.WriteLine($" [retail sky] cache.reserv = 0x{reservedVal:X8} (008682d4, written 0 by 00505f30:6065)");
Console.WriteLine($" [retail sky] cache.type = 0x{lightType:X8} (008682d8, 3 = directional)");
Console.WriteLine("=================================================================");
}
/// <summary>
/// Format a packed ARGB u32 as "#AARRGGBB (r=.. g=.. b=..)". Retail uses the
/// standard Windows D3DCOLOR layout verified against FUN_00451a60 (chunk
/// _00450000.c:615-622): float R = (u &gt;&gt; 16) &amp; 0xff, G = (u &gt;&gt; 8) &amp; 0xff,
/// B = u &amp; 0xff, each divided by 255.
/// </summary>
private static string FormatArgb(uint argb)
{
byte a = (byte)((argb >> 24) & 0xff);
byte r = (byte)((argb >> 16) & 0xff);
byte g = (byte)((argb >> 8) & 0xff);
byte b = (byte)( argb & 0xff);
return $"#{a:X2}{r:X2}{g:X2}{b:X2} (r={r / 255.0f:F3} g={g / 255.0f:F3} b={b / 255.0f:F3})";
}
private static byte[] ReadBytes(IntPtr handle, IntPtr address, int count)
{
byte[] buf = new byte[count];
if (!ReadProcessMemory(handle, address, buf, buf.Length, out _))
throw new InvalidOperationException(
$"ReadProcessMemory(0x{address.ToInt64():X8}, {count}) failed " +
$"(Win32 error {Marshal.GetLastPInvokeError()})");
return buf;
}
private static uint ReadUInt32(IntPtr handle, IntPtr address)
{
byte[] buf = new byte[4];

View file

@ -0,0 +1,175 @@
// SkyObjectInspect — throwaway probe for the Dereth stars mystery.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SysEnv = System.Environment;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
Console.WriteLine($"datDir = {datDir}");
using var dats = new DatCollection(datDir, DatAccessType.Read);
if (!dats.TryGet<Region>(0x13000000u, out var region) || region is null)
{
Console.Error.WriteLine("ERROR: Cannot read Region 0x13000000");
return 1;
}
Console.WriteLine($"Region loaded. SkyInfo.DayGroups count: {region.SkyInfo?.DayGroups?.Count ?? -1}");
var interesting = new[] { 0, 8 };
foreach (int dg in interesting)
{
if (region.SkyInfo?.DayGroups is null || dg >= region.SkyInfo.DayGroups.Count) continue;
var group = region.SkyInfo.DayGroups[dg];
Console.WriteLine();
Console.WriteLine($"=== DayGroup[{dg}] Name=\"{group.DayName?.Value}\" Chance={group.ChanceOfOccur:F3} SkyObjs={group.SkyObjects.Count} SkyTimes={group.SkyTime.Count} ===");
for (int oi = 0; oi < group.SkyObjects.Count; oi++)
{
var so = group.SkyObjects[oi];
Console.WriteLine($" OI={oi}: Begin={so.BeginTime:F3} End={so.EndTime:F3} BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1} TexVel=({so.TexVelocityX:F3},{so.TexVelocityY:F3}) Gfx=0x{(uint)so.DefaultGfxObjectId:X8} Pes=0x{(uint)so.DefaultPesObjectId:X8} Props=0x{so.Properties:X8}");
}
// Show every SkyTime's SkyObjectReplace entries — this tells us if any OI
// actually changes at night.
foreach (var skytime in group.SkyTime.OrderBy(s => s.Begin))
{
Console.WriteLine($" [SkyTime @ Begin={skytime.Begin:F3}] Replaces={skytime.SkyObjReplace.Count}");
foreach (var r in skytime.SkyObjReplace)
{
Console.WriteLine($" OI={r.ObjectIndex}: Gfx=0x{(uint)r.GfxObjId:X8} Rot={r.Rotate:F2} Transp={r.Transparent:F3} Lum={r.Luminosity:F3} MaxB={r.MaxBright:F3}");
}
}
}
// Also scan ALL DayGroups for any SkyObject with BeginTime > EndTime (wrap)
// OR BeginTime in late night (>0.75) with a gfx that could be stars.
Console.WriteLine();
Console.WriteLine("=== Scan: any SkyObject with night-spanning window (begin>0.7 or end<0.3 wrap-candidate) across ALL DayGroups ===");
int nFound = 0;
if (region.SkyInfo?.DayGroups is not null)
{
for (int dg = 0; dg < region.SkyInfo.DayGroups.Count; dg++)
{
var group = region.SkyInfo.DayGroups[dg];
for (int oi = 0; oi < group.SkyObjects.Count; oi++)
{
var so = group.SkyObjects[oi];
bool wrap = so.BeginTime > so.EndTime && so.BeginTime != so.EndTime;
bool late = so.BeginTime > 0.7f;
bool early = so.EndTime < 0.3f && so.EndTime > 0f;
if (wrap || late || early)
{
Console.WriteLine($" DG[{dg}]=\"{group.DayName?.Value}\" OI={oi} Begin={so.BeginTime:F3} End={so.EndTime:F3} Gfx=0x{(uint)so.DefaultGfxObjectId:X8} wrap={wrap} late={late} early={early}");
nFound++;
}
}
}
}
Console.WriteLine($" (found {nFound} night-window candidates)");
// Candidate GfxObjs for Sunny.
var candidateIds = new uint[] { 0x010015EEu, 0x010015EFu, 0x01001F6Au, 0x01004C36u, 0x02000714u };
foreach (uint gid in candidateIds)
{
Console.WriteLine();
Console.WriteLine($"=== GfxObj 0x{gid:X8} ===");
if (gid >= 0x02000000u)
{
if (dats.TryGet<Setup>(gid, out var setup) && setup is not null)
{
Console.WriteLine($" [Setup] Parts={setup.Parts.Count}");
for (int pi = 0; pi < setup.Parts.Count; pi++)
{
uint partGid = (uint)setup.Parts[pi];
Console.WriteLine($" Part[{pi}] = GfxObj 0x{partGid:X8}");
DumpGfxObj(dats, partGid, indent: " ");
}
}
else
{
Console.WriteLine(" (not a Setup or not found)");
}
continue;
}
DumpGfxObj(dats, gid, indent: " ");
}
return 0;
static void DumpGfxObj(DatCollection dats, uint gid, string indent)
{
if (!dats.TryGet<GfxObj>(gid, out var go) || go is null)
{
Console.WriteLine($"{indent}(GfxObj 0x{gid:X8} not found)");
return;
}
Console.WriteLine($"{indent}GfxObj 0x{gid:X8}: Flags=0x{(uint)go.Flags:X8} Surfaces={go.Surfaces.Count} Polys={go.Polygons.Count} Verts={go.VertexArray?.Vertices?.Count ?? 0}");
for (int si = 0; si < go.Surfaces.Count; si++)
{
uint sid = (uint)go.Surfaces[si];
if (!dats.TryGet<Surface>(sid, out var surf) || surf is null)
{
Console.WriteLine($"{indent} Surf[{si}]=0x{sid:X8} (not found)");
continue;
}
string texDesc = DescribeTexture(dats, surf);
Console.WriteLine($"{indent} Surf[{si}]=0x{sid:X8} Type={surf.Type} Translucency={surf.Translucency:F3} Luminosity={surf.Luminosity:F3} Diffuse={surf.Diffuse:F3} Tex=[{texDesc}]");
}
}
static string DescribeTexture(DatCollection dats, Surface surf)
{
if (!(surf.Type.HasFlag(SurfaceType.Base1Image) || surf.Type.HasFlag(SurfaceType.Base1ClipMap)))
return $"solid color A=0x{surf.ColorValue.Alpha:X2} R=0x{surf.ColorValue.Red:X2} G=0x{surf.ColorValue.Green:X2} B=0x{surf.ColorValue.Blue:X2}";
uint stid = (uint)surf.OrigTextureId;
if (stid == 0) return "no-texture";
if (!dats.TryGet<SurfaceTexture>(stid, out var st) || st is null)
return $"SurfaceTex 0x{stid:X8} missing";
if (st.Textures.Count == 0) return $"SurfaceTex 0x{stid:X8} empty";
uint rsid = (uint)st.Textures[0];
if (!dats.TryGet<RenderSurface>(rsid, out var rs) || rs is null)
return $"RenderSurf 0x{rsid:X8} missing";
double brightRatio = ApproxBrightRatio(rs);
return $"{rs.Width}x{rs.Height} {rs.Format} data={rs.SourceData.Length}B palette=0x{rs.DefaultPaletteId:X8} brightRatio~{brightRatio:F3}";
}
static double ApproxBrightRatio(RenderSurface rs)
{
if (rs.SourceData is null || rs.SourceData.Length == 0) return 0;
if (rs.Format == PixelFormat.PFID_A8R8G8B8)
{
int bright = 0, total = rs.SourceData.Length / 4;
for (int i = 0; i + 4 <= rs.SourceData.Length; i += 4)
{
byte a = rs.SourceData[i];
byte r = rs.SourceData[i + 1];
byte g = rs.SourceData[i + 2];
byte b = rs.SourceData[i + 3];
if (a > 0 && (r + g + b) / 3 > 48) bright++;
}
return total > 0 ? (double)bright / total : 0;
}
if (rs.Format == PixelFormat.PFID_R8G8B8)
{
int bright = 0, total = rs.SourceData.Length / 3;
for (int i = 0; i + 3 <= rs.SourceData.Length; i += 3)
{
byte r = rs.SourceData[i];
byte g = rs.SourceData[i + 1];
byte b = rs.SourceData[i + 2];
if ((r + g + b) / 3 > 48) bright++;
}
return total > 0 ? (double)bright / total : 0;
}
int nonZero = 0;
for (int i = 0; i < rs.SourceData.Length; i++) if (rs.SourceData[i] != 0) nonZero++;
return (double)nonZero / rs.SourceData.Length;
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SkyObjectInspect</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
</ItemGroup>
</Project>