Compare commits
10 commits
1e1d3875f7
...
1d54880213
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d54880213 | ||
|
|
889b235886 | ||
|
|
e4cf3a9b6b | ||
|
|
2e9a836f08 | ||
|
|
845d70248c | ||
|
|
8a42750459 | ||
|
|
53608e77e3 | ||
|
|
d5e37694ed | ||
|
|
2802fb2151 | ||
|
|
3a117bd91a |
25 changed files with 4198 additions and 197 deletions
169
README.md
169
README.md
|
|
@ -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.
|
||||
|
|
|
|||
136
docs/plans/2026-04-23-sky-weather-lightning-port.md
Normal file
136
docs/plans/2026-04-23-sky-weather-lightning-port.md
Normal 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 (8–30 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).
|
||||
438
docs/research/2026-04-23-lightning-crossfade.md
Normal file
438
docs/research/2026-04-23-lightning-crossfade.md
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
# Lightning Flashes & Weather Crossfade — Decompile Research
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Scope:** Answer Q1–Q5 of the lightning-crossfade hunt brief.
|
||||
**Source tree:** `docs/research/decompiled/chunk_*.c` (688K lines, decompiled retail acclient.exe).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Retail has NO lightning-flash system.** Not a timer, not an RNG modulator, not a
|
||||
visual spike. Storm preset 6 sets two fog-color targets (grey 0x969696) and
|
||||
toggles the crossfade; that's it. "Flashes" in modern ports are an addition.
|
||||
2. **Weather crossfade is driven entirely by `FUN_0055eb40`** (chunk_00550000.c:11835)
|
||||
— a 7-way switch on `EnvironChangeType` (param_2). It sets fog-crossfade target
|
||||
globals (`DAT_008427ac/b0/b4`, `DAT_00842784/88`), sets `DAT_008427a9 = 1`
|
||||
(active), and resets `_DAT_008427b8 = 0` (progress u).
|
||||
3. **Crossfade step `_DAT_007c7208`** is a single rdata constant. It's
|
||||
added each time the `LightTickSize` gate fires (i.e. per sky-keyframe update,
|
||||
default ~2 seconds). Progress saturates at 1.0 (`_DAT_007938b0`).
|
||||
4. **AdminEnvirons (0xEA60 = 60000) arrives via `FUN_006ae870`**
|
||||
(chunk_006A0000.c:13141) and unconditionally calls `FUN_0055eb40` with the
|
||||
EnvironChangeType int. No auth check, no queueing.
|
||||
5. **Thunder audio (0x76..0x7B)** is driven by AdminEnvirons subtypes **0x65..0x6A**
|
||||
(chunk_00550000.c:11906-11994) — each calls `FUN_00551560(soundId, chanId)` ONCE.
|
||||
No timer. Not auto-linked to storm preset.
|
||||
|
||||
---
|
||||
|
||||
## Q1: Lightning flash trigger — NOT PRESENT in retail
|
||||
|
||||
### What I searched
|
||||
- `rand()` in chunk_00500000.c (sky): **3 hits, all inside `FUN_00501600` RNG-looking
|
||||
macros → actually a byte-shuffle for ARGB color lerping** (`FUN_005df4c4`), not RNG.
|
||||
- `rand()` in chunk_00550000.c (weather): **3 hits (lines 646, 1074, 1102) — all
|
||||
sound-probability filters in `FUN_00550cf0/FUN_00551430/FUN_005514c0`**, used by
|
||||
ambient-sound emission, not lightning.
|
||||
- `rand()` in chunk_005B0000.c:3176-3189 — 256-entry palette shuffle init, unrelated.
|
||||
- `rand()` in chunk_005C0000.c:5560-5668 — 4 particle-emitter time-jitter seeds,
|
||||
unrelated.
|
||||
- `fsinf`/`fcosf` in the sky-keyframe path — only used for sun-direction polar-to-
|
||||
cartesian conversion (`FUN_00501600:1193-1205`). No other time-based trig.
|
||||
- String literals `"lightning"|"Lightning"|"thunder"|"Thunder"|"LIGHTNING"|"THUNDER"`:
|
||||
**one hit, unrelated** (chunk_004B0000.c:2283 = a character-skill UI string
|
||||
`"ID_CharacterInfo_Augmentation_Resist_Lightning"`).
|
||||
- Storm preset 6 in `FUN_0055eb40` — sets `*(iVar2 + 0x41) = 1` on the singleton
|
||||
`DAT_00871354` (via `FUN_00564d30`). I grepped for READS of `+0x41` across the
|
||||
entire decompile: **there are NONE** outside the singleton's own ctor/reset
|
||||
paths (chunk_00560000.c:2902, 3105 — both writes of 0). **The storm flag is
|
||||
write-only; no lightning tick consumes it.**
|
||||
|
||||
### Storm preset 6 body — chunk_00550000.c:11885-11896
|
||||
|
||||
```c
|
||||
if (param_2 == 6) {
|
||||
DAT_008427a9 = 1; // crossfade active
|
||||
_DAT_008427b8 = 0; // progress u
|
||||
DAT_008427ac = 0x3f4ccccd; // = 0.8f (target fogStart)
|
||||
DAT_008427b0 = 0; // target secondary-1 (fogNear)
|
||||
DAT_008427b4 = 0x42200000; // = 40.0f (target fogFar)
|
||||
DAT_00842788 = 0x64969696; // target fog color ARGB: A=0x64 R=G=B=0x96 grey
|
||||
DAT_00842784 = 0x64000000; // target secondary color: A=0x64 RGB=black
|
||||
iVar2 = FUN_00564d30(); // get weather-mgr singleton
|
||||
*(undefined1 *)(iVar2 + 0x41) = 1; // storm flag (NEVER READ elsewhere)
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
|
||||
The retail acclient **does not implement lightning flashes**. Storm preset 6 is
|
||||
visually indistinguishable from other fog-change presets except by color and
|
||||
the unread `+0x41` storm flag. The "client-side random flash" described in the
|
||||
r12 deepdive is either:
|
||||
(a) a later/expansion feature not present in the decompiled build, or
|
||||
(b) a modern-port embellishment.
|
||||
|
||||
If acdream wants lightning, it's an **addition**, not a port. A faithful retail
|
||||
render is pure dense grey fog during thunderstorm.
|
||||
|
||||
---
|
||||
|
||||
## Q2: Weather / DayGroup crossfade mechanics
|
||||
|
||||
### State variables (all in the 0x842780 cluster)
|
||||
|
||||
| Global | Type | Init | Role |
|
||||
|---|---|---|---|
|
||||
| `DAT_008427a9` | byte | 0 | **Crossfade active flag** — true = blend keyframe output toward stored weather values |
|
||||
| `_DAT_008427b8` | float | 0.0 | **Crossfade progress `u`** — 0 at start, saturates at 1.0 |
|
||||
| `DAT_008427ac` | float | — | **Target fogStart** (weather override) |
|
||||
| `DAT_008427b0` | float | — | **Target fogNear** (secondary/starfield override) |
|
||||
| `DAT_008427b4` | float | — | **Target fogFar** (secondary/starfield override) |
|
||||
| `DAT_00842788` | u32 ARGB | — | **Target primary fog color** (pair with `DAT_008427ac`) |
|
||||
| `DAT_00842784` | u32 ARGB | — | **Target secondary color** (pair with `DAT_008427b0/b4`) |
|
||||
| `_DAT_007c7208` | float | **.rdata constant** (value unknown in decompile; see below) | **Per-tick progress step** |
|
||||
| `_DAT_007938b0` | float | 1.0 (confirmed by division usage across chunk_00440000/00450000) | Upper saturation for `u` |
|
||||
|
||||
### Per-frame crossfade block — chunk_00500000.c:6256-6281 (primary channel)
|
||||
|
||||
```c
|
||||
if (DAT_008427a9 != '\0') {
|
||||
if (_DAT_007938b0 <= _DAT_008427b8) { // u >= 1.0: snap
|
||||
local_24 = DAT_008427ac; // fogStart = target
|
||||
local_20 = DAT_00842788; // fogColor ARGB = target
|
||||
}
|
||||
else {
|
||||
// Per-byte lerp on fog color (R, G, B, A individually):
|
||||
// new = current - (current - target) * u [applied to each byte]
|
||||
// -- FUN_005df4c4 is the byte clamp/pack helper --
|
||||
... // 4 byte channels
|
||||
local_24 = local_24 - (local_24 - DAT_008427ac) * _DAT_008427b8; // fogStart lerp
|
||||
_DAT_008427b8 = _DAT_008427b8 + _DAT_007c7208; // advance u
|
||||
}
|
||||
}
|
||||
FUN_00505f30(local_24, local_20, local_c, local_18);
|
||||
```
|
||||
|
||||
### Per-frame crossfade block — chunk_00500000.c:6297-6324 (secondary / starfield)
|
||||
|
||||
Same structure, but writes `local_1c` (fogNear) ← `DAT_008427b0`, `local_24`
|
||||
(fogFar) ← `DAT_008427b4`, `local_20` (color) ← `DAT_00842784`. Progress `u` is
|
||||
the SAME global `_DAT_008427b8` — so both channels advance in lockstep.
|
||||
|
||||
### Important: the crossfade step gate
|
||||
|
||||
`_DAT_008427b8 += _DAT_007c7208` runs ONLY when the outer "LightTickSize" gate fires
|
||||
(chunk_00500000.c:6249 `if (_DAT_008427a0 < _DAT_008379a8)`). This gate reschedules
|
||||
using `*(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10)` = SkyDesc.LightTickSize.
|
||||
Based on the ACE schema (SkyDesc.LightTickSize in the Region dat), this is
|
||||
typically 2.0 seconds.
|
||||
|
||||
**Duration of a crossfade**: if `_DAT_007c7208` is, say, 1/30 (0.033), then
|
||||
crossfade completes in 30 light-ticks × 2s = 60 seconds. If it's 1/8 = 0.125,
|
||||
it's 16 seconds. If it's 1.0, it's 2 seconds (instant within one keyframe step).
|
||||
The literal value is in .rdata at offset 0x007c7208 and isn't visible in the
|
||||
decompile — acdream should either (a) start with a tuning-chosen constant
|
||||
(e.g. 0.1 for 20 s fade) and expose it as a config, or (b) disassemble the
|
||||
retail binary's .rdata to get the ground-truth value.
|
||||
|
||||
### Note on retail client behavior
|
||||
|
||||
Because the crossfade step advances at the LightTickSize cadence (not per-frame),
|
||||
retail's weather change visibly "steps" in ~2-second increments rather than
|
||||
appearing silky smooth at 60 fps. This matches the known retail look —
|
||||
"the sky is updating in chunks" rather than continuously.
|
||||
|
||||
---
|
||||
|
||||
## Q3: AdminEnvirons (0xEA60 = 60000) handler
|
||||
|
||||
### Dispatcher — chunk_006A0000.c:13141-13153
|
||||
|
||||
```c
|
||||
undefined4 FUN_006ae870(int param_1, int *param_2)
|
||||
{
|
||||
undefined4 uVar1;
|
||||
if (((param_1 != 0) && (*(int *)(param_1 + 0x40) != 0)) && (*param_2 == 60000)) {
|
||||
uVar1 = FUN_0055eb40(param_2[1]); // param_2[1] = EnvironChangeType (int)
|
||||
return uVar1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Wire format: `[u32 opcode=0xEA60][u32 environChangeType]` — just a single int payload.
|
||||
|
||||
### `FUN_0055eb40` — EnvironChangeType dispatcher (chunk_00550000.c:11839)
|
||||
|
||||
| EnvChangeType | Action | Crossfade? |
|
||||
|---|---|---|
|
||||
| 0 (Clear) | Zero all targets; set 008427a9 = 0 (crossfade OFF) | N (off) |
|
||||
| 1 (RedFog / preset 1) | fogStart 0.4, fogFar 50, color 0x64_R_96_00 | Y |
|
||||
| 2 (preset 2) | fogStart 0.3, fogFar 50, color 0x64_32_00_96 | Y |
|
||||
| 3 (preset 3) | fogStart 0.4, fogFar 30, color 0x64_64_64_64 (grey) | Y |
|
||||
| 4 (preset 4) | fogStart 0.3, fogFar 50, color 0x64_1E_64_00 | Y |
|
||||
| 5 (preset 5) | fogStart 0.8, fogFar 40, color 0x64_96_96_96 | Y |
|
||||
| **6 (Storm)** | fogStart 0.8, fogFar 40, color 0x64_96_96_96 + `singleton+0x41 = 1` | Y |
|
||||
| 0x65..0x72 | Play thunder/ambient sound via `FUN_00551560(soundId 0x76..0x83, chanObj)` | N (sound only) |
|
||||
| 0x75..0x7B | Play thunder/ambient sound (0x84..0x8A) | N (sound only) |
|
||||
| 9999 (preset 9999) | fogFar 30, color 0x32_64_64_64, same as preset 3 branch | Y |
|
||||
|
||||
All "crossfade" branches set `DAT_008427a9 = 1` and `_DAT_008427b8 = 0` via the
|
||||
common `LAB_0055f050` tail.
|
||||
|
||||
The common tail (chunk_00550000.c:12009-12015):
|
||||
```c
|
||||
DAT_008427a9 = 1;
|
||||
LAB_0055f050:
|
||||
_DAT_008427b8 = 0;
|
||||
DAT_008427b0 = 0; // reset fogNear target
|
||||
iVar2 = FUN_00564d30();
|
||||
*(undefined1 *)(iVar2 + 0x41) = 0; // clear storm flag
|
||||
return 0;
|
||||
```
|
||||
|
||||
### AdminEnvirons → crossfade trigger
|
||||
|
||||
The server's `AdminEnvirons(EnvironChangeType = 6)` path:
|
||||
1. Client wire: opcode 0xEA60 followed by u32=6.
|
||||
2. `FUN_006ae870` dispatches on opcode, calls `FUN_0055eb40(6)`.
|
||||
3. `FUN_0055eb40` writes the storm targets + sets the crossfade flag.
|
||||
4. Next `FUN_005062e0` tick (gated by `LightTickSize`) lerps toward the targets.
|
||||
5. Crossfade continues at step `_DAT_007c7208` per tick until `u >= 1`.
|
||||
|
||||
---
|
||||
|
||||
## Q4: Thunder sound wiring
|
||||
|
||||
### Direct, not timer-driven
|
||||
|
||||
`FUN_00551560(soundId, chanObj)` is the play-sound-now call. `FUN_00564d50(singleton)`
|
||||
lazily instantiates the channel object `FUN_00415730(0x10000003, 7, 0x22)` and
|
||||
caches it at `singleton + 0x34`. Each EnvironChangeType 0x65..0x6A plays a
|
||||
DIFFERENT thunder/ambient sound:
|
||||
|
||||
| EnvChangeType | soundId | Likely meaning |
|
||||
|---|---|---|
|
||||
| 0x65 (101) | 0x76 | Thunder1Sound |
|
||||
| 0x66 (102) | 0x77 | Thunder2Sound |
|
||||
| 0x67 (103) | 0x78 | Thunder3Sound |
|
||||
| 0x68 (104) | 0x79 | Thunder4Sound |
|
||||
| 0x69 (105) | 0x7A | Thunder5Sound |
|
||||
| 0x6A (106) | 0x7B | Thunder6Sound |
|
||||
| 0x6B..0x72 (107-114) | 0x7C..0x83 | other ambient sounds |
|
||||
| 0x75..0x7B (117-123) | 0x84..0x8A | more ambient sounds |
|
||||
|
||||
**There is NO periodic "play thunder" call.** The retail client plays thunder
|
||||
ONLY when the server sends `AdminEnvirons(0x65..0x6A)`. No client-side RNG
|
||||
picks a sound, no tick schedules anything. If the server wants "thunder every
|
||||
10-20 seconds during storm", **the server must send it explicitly.**
|
||||
|
||||
Cross-confirmation: `FUN_00551560(0x76, ...)` appears in the full decompile
|
||||
only ONCE (chunk_00550000.c:11909). Every other thunder/ambient sound is also
|
||||
a single-site dispatch from `FUN_0055eb40`. There is no storm-active loop.
|
||||
|
||||
---
|
||||
|
||||
## Q5: Port-ready C# pseudocode
|
||||
|
||||
### 1. Crossfade state machine
|
||||
|
||||
```csharp
|
||||
// Source of truth: ACE/retail AC EnvironChangeType enum + the 7 cases of FUN_0055eb40
|
||||
// (chunk_00550000.c:11839-12016).
|
||||
public enum EnvironChangeType : uint
|
||||
{
|
||||
Clear = 0,
|
||||
// Preset 1..6 are the historical fog presets. Values match FUN_0055eb40 switch.
|
||||
Fog1 = 1, // 0x64_B2_96_00-ish, fogStart 0.4, fogFar 50
|
||||
Fog2 = 2,
|
||||
Fog3 = 3,
|
||||
Fog4 = 4,
|
||||
Fog5 = 5,
|
||||
Storm = 6, // fogStart 0.8, fogFar 40, grey
|
||||
Thunder1 = 0x65,
|
||||
Thunder2 = 0x65 + 1,
|
||||
// ...through 0x7B
|
||||
Fog9999 = 9999,
|
||||
}
|
||||
|
||||
internal sealed class WeatherCrossfade
|
||||
{
|
||||
// Retail globals (DAT_008427a9, DAT_008427ac, DAT_008427b0, DAT_008427b4, _DAT_008427b8,
|
||||
// DAT_00842788, DAT_00842784).
|
||||
private bool _active;
|
||||
private float _progressU;
|
||||
private float _targetFogStart;
|
||||
private float _targetFogNear;
|
||||
private float _targetFogFar;
|
||||
private uint _targetFogColorArgb;
|
||||
private uint _targetSecondaryArgb;
|
||||
|
||||
// Retail constant _DAT_007c7208 (.rdata). Per light-tick increment. Literal value is
|
||||
// not in the decompile; 0.1 gives ~20s crossfade at default LightTickSize=2s.
|
||||
// TODO(acdream): disassemble retail .rdata @ 0x007c7208 to pin the exact value.
|
||||
public float ProgressStep { get; set; } = 0.1f;
|
||||
|
||||
/// <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.
|
||||
398
docs/research/2026-04-23-lightning-real.md
Normal file
398
docs/research/2026-04-23-lightning-real.md
Normal 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 H1–H5 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 0x65–0x6A, 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.05–0.3s lifespan).
|
||||
- **One or two `SoundHook` entries** with `StartTime` offset by 1–5 seconds
|
||||
(light-then-thunder) referencing Thunder1–6 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.
|
||||
502
docs/research/2026-04-23-physicsscript.md
Normal file
502
docs/research/2026-04-23-physicsscript.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
335
docs/research/2026-04-23-sky-fog.md
Normal file
335
docs/research/2026-04-23-sky-fog.md
Normal 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.
|
||||
441
docs/research/2026-04-23-sky-material-state.md
Normal file
441
docs/research/2026-04-23-sky-material-state.md
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# Sky Material/D3D State — Retail Decompile Trace
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Scope:** Q1–Q6 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)
|
||||
184
docs/research/2026-04-23-sky-pes-wiring.md
Normal file
184
docs/research/2026-04-23-sky-pes-wiring.md
Normal 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 Q1–Q4 has the same answer: **retail doesn't do it here.** The PES-from-SkyObject pathway is dead code at the render stage — either disabled in retail, or the id is consumed by code outside `chunk_00500000.c` that isn't called from the sky path we traced. The r12 deepdive note at `deepdives/r12-weather-daynight.md:423-426` corroborates: *"Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` … **that attaches a particle emitter to the camera**."* The emitter lives on the camera, not on the sky entity, and the dat files for retail-shipped regions don't actually populate it on any sky object the audit has examined.
|
||||
|
||||
Full evidence below.
|
||||
|
||||
---
|
||||
|
||||
## Q1 — PES-start call site inside `FUN_00508010`
|
||||
|
||||
**There is none.** Full loop body (`chunk_00500000.c:7567-7599`):
|
||||
|
||||
```c
|
||||
do {
|
||||
if (*(int *)(param_1[3] + uVar7 * 4) != 0) { // slot has GfxObjId?
|
||||
uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1); // +0x08 = Rotate override (NOT Pes)
|
||||
uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc); // +0x0c = Arc angle
|
||||
local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0; // identity quat
|
||||
local_14 = 0; local_10 = 0; local_c = 0; // zero translation
|
||||
FUN_00535b30(); // reset current xform
|
||||
if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { // Properties bit 2 set?
|
||||
iVar5 = *(int *)param_1[3];
|
||||
local_14 = *(undefined4 *)(iVar5 + 0x84); // custom translation X
|
||||
local_10 = *(undefined4 *)(iVar5 + 0x88); // Y
|
||||
local_c = *(undefined4 *)(iVar5 + 0x8c); // Z
|
||||
}
|
||||
FUN_005079e0(&local_48, uVar3, uVar4); // rotate (mesh-roll + arc)
|
||||
FUN_00514b90(&local_48); // enqueue mesh draw
|
||||
if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1))
|
||||
FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0); // Luminosity
|
||||
if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1))
|
||||
FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0); // MaxBright
|
||||
if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1))
|
||||
FUN_005120c0( *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0); // Transparent
|
||||
}
|
||||
uVar7 = uVar7 + 1;
|
||||
iVar6 = iVar6 + 0x2c;
|
||||
} while (uVar7 < uVar2);
|
||||
```
|
||||
|
||||
**Offsets touched inside the loop:** `+0x08, +0x0c, +0x1c, +0x20, +0x24` and the Properties byte. **`+0x04` (the PesObjectId slot) is NEVER read** anywhere in this function or in `FUN_004ff4b0`/`FUN_00502a10`'s render-time code path. A grep confirms no occurrence of `iVar6 + 4 + *param_1` or `iVar6 + 0x04 + *param_1` in `chunk_00500000.c`.
|
||||
|
||||
The previous audit (`2026-04-23-sky-decompile-hunt-A.md` §5.3) inferred `uVar3` was rotation-axis-1, but labeled its source as "unknown field at +8". That field is **the `Rotate` override from `SkyObjectReplace+0x0c`** — proven by `FUN_00502a10:2532-2534`:
|
||||
|
||||
```c
|
||||
fVar1 = *(float *)(*(int *)(local_34 + 0x2c) + local_38 * 4) + 0xc); // Replace.Rotate
|
||||
if (fVar1 != DAT_00796344) {
|
||||
*(float *)(uVar6 * 0x2c + 8 + *piVar5) = fVar1; // stored at per-frame+0x08
|
||||
}
|
||||
```
|
||||
|
||||
So the `+0x08` slot is a **mesh-roll angle**, not a PhysicsScript pointer.
|
||||
|
||||
---
|
||||
|
||||
## Q2 — PES lifecycle for visible SkyObjects
|
||||
|
||||
**There is no lifecycle.** The sky draw path does not:
|
||||
1. Allocate a PES instance per SkyObject
|
||||
2. Hold a "currently-running PES" back-pointer anywhere in SkyObject, per-frame table entry, Region, SkyDesc, or DayGroup
|
||||
3. Call `FUN_0051bed0` (the PhysicsScript launcher) anywhere in the sky-render tree (`FUN_005062e0`, `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, `FUN_00507e20`, `FUN_005079e0`, `FUN_00514b90`)
|
||||
|
||||
Verified by:
|
||||
```
|
||||
$ grep -n "FUN_0051bed0\|FUN_0051be40\|FUN_0051bfb0\|FUN_0051c040" chunk_00500000.c
|
||||
(no results)
|
||||
```
|
||||
|
||||
`FUN_0051bed0` (the PhysicsScript runner) is located in `chunk_00510000.c:11121`:
|
||||
```c
|
||||
undefined4 FUN_0051bed0(undefined4 param_1) { // param_1 = PhysicsScript dat ID
|
||||
uVar1 = FUN_004220b0(param_1, 0x2b); // type 0x2b = PHYSICS_SCRIPT
|
||||
iVar2 = FUN_00415430(uVar1); // dat-load
|
||||
if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { // queue
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Its only caller is `FUN_005117a0` (`chunk_00510000.c:1504`), which is the **PhysicsObject::RunScript** method:
|
||||
```c
|
||||
undefined4 FUN_005117a0(int param_1, int param_2) { // this=PhysicsObject, param_2=ScriptId
|
||||
if (*(int *)(param_1 + 0x30) == 0) { // lazy-alloc ScriptManager at +0x30
|
||||
iVar1 = FUN_005df0f5(0x18);
|
||||
if (iVar1 == 0) uVar2 = 0;
|
||||
else uVar2 = FUN_0051be20(param_1);
|
||||
*(undefined4 *)(param_1 + 0x30) = uVar2;
|
||||
}
|
||||
if (*(int *)(param_1 + 0x30) != 0) {
|
||||
uVar3 = FUN_0051bed0(param_2);
|
||||
}
|
||||
return uVar3;
|
||||
}
|
||||
```
|
||||
|
||||
Every caller of `FUN_005117a0` is in PhysicsObject / weapon / combat code (`chunk_00510000.c:2432, 2470, 3719, 3741, 3771, 4190, 4855, 5231, 5261`). **None are in the sky renderer.**
|
||||
|
||||
---
|
||||
|
||||
## Q3 — Day-change & DayGroup-change handling
|
||||
|
||||
No such code. The SkyObject table rebuild in `FUN_00502a10` (triggered every frame via `FUN_004ff4b0`) does:
|
||||
1. Grows/shrinks the output table size to match current DayGroup's `SkyObject.Count` (lines 2430-2480)
|
||||
2. For each SkyObject, copies `GfxObjId/PesObjectId/Properties/Rotate/ArcAngle/TexVel` into the per-frame entry
|
||||
3. Overlays the current SkyTimeOfDay's `SkyObjectReplace[]` entries
|
||||
|
||||
**Nothing in this rebuild path allocates, cleans up, or references a PhysicsScript owner.** `FUN_00502a10` treats `PesObjectId` as an opaque dword — copy from `SkyObject+0x28` to per-frame entry `+0x04` (line 2492) — and that's the last time it's touched.
|
||||
|
||||
The only "lifecycle" seen is the DayGroup variant roll (`FUN_00501990`), which re-rolls *which* DayGroup is active based on a deterministic hash of the player weenie's state. That affects which `SkyObject[]` gets iterated, but again — nothing in the DayGroup-change path touches PES.
|
||||
|
||||
---
|
||||
|
||||
## Q4 — The particle-emitter parent
|
||||
|
||||
Per the r12 deepdive `deepdives/r12-weather-daynight.md:423-426, 447-476`:
|
||||
|
||||
> Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the sky object) **that attaches a particle emitter to the camera**. This emitter fires rain/snow particles regardless of the server.
|
||||
|
||||
> Rain in AC is a `ParticleEmitter` **attached to the camera** at an offset of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing streak-style particles downward.
|
||||
|
||||
So the **owner is the camera PhysicsObject**, not any SkyObject. When (if) retail does emit weather particles, it's via the camera's own `RunScript` invoked from a code path we haven't traced — likely a weather manager hooked to `EnvironChange` events, not to the sky-render loop.
|
||||
|
||||
Given the `DefaultPesObjectId` isn't read during render, the most likely place it would be consumed is **region-load time** — when `FUN_004ff370` loads the Region and its SkyDesc, a weather manager could walk every SkyObject, find any non-zero PesObjectId, and use it to initialize a camera-attached emitter template. But no such code was found inside `chunk_00500000.c` or the Region loader path; it would live in a separate weather/particle subsystem (probably `chunk_00510000.c` or `chunk_005A0000.c`).
|
||||
|
||||
---
|
||||
|
||||
## Q5 — Port-ready pseudocode
|
||||
|
||||
Because retail does not run PES per sky object, the port pseudocode is the null program:
|
||||
|
||||
```
|
||||
frame tick:
|
||||
for each SkyObject in current DayGroup:
|
||||
# exactly what FUN_00508010 does — draw the mesh, apply T/L/MB overrides.
|
||||
# DefaultPesObjectId is copied into the per-frame table at +0x04 but never read.
|
||||
visible_now = (BeginTime == EndTime) OR (BeginTime < t < EndTime)
|
||||
if visible_now AND entry.GfxObjId != 0:
|
||||
draw mesh with Rotate/ArcAngle rotations
|
||||
apply Luminosity/MaxBright/Transparent overrides if > 0
|
||||
# NO PES START/STOP/UPDATE
|
||||
|
||||
on DayGroup change:
|
||||
# FUN_00501990 re-rolls active DayGroup index by deterministic hash.
|
||||
# Does NOT touch any script state.
|
||||
nop
|
||||
|
||||
on Region unload:
|
||||
# FUN_004ff3b0 releases Region via vtable[0x14]; no sky-specific PES cleanup.
|
||||
nop
|
||||
```
|
||||
|
||||
**What to do for acdream:**
|
||||
- **Ship Phase 2 sky as geometry-only.** Do NOT add a SkyObject→ParticleEmitter spawn path based on `DefaultPesObjectId`. It would not match retail.
|
||||
- **Retain `DefaultPesObjectId` in the parsed struct** (we already do — `SkyObject.DefaultPesObjectId` in `SkyState.cs`). It's data retail loads but doesn't use at render; keep it so future weather code can inspect it if we implement the camera-emitter path.
|
||||
- **Weather particles are a SEPARATE feature.** If/when implemented, they belong in a `WeatherManager` that lives next to `WeatherState` enum + `EnvironChange` handling, attaches emitters to the camera entity, and is triggered by region-load + fog-keyframe transitions. That manager *may* scan each SkyObject's `DefaultPesObjectId` as one of its inputs, or it may use a hard-coded per-WeatherState table (rain.pes, snow.pes). Either approach is off the sky-render critical path.
|
||||
|
||||
---
|
||||
|
||||
## Confidence
|
||||
|
||||
- **High**: `FUN_00508010` does not call PES. Evidence: full line-by-line read; grep of entire `chunk_00500000.c` for any `FUN_0051bXX` / `FUN_0051cXX` — zero hits.
|
||||
- **High**: `FUN_00502a10` copies PesObjectId through but doesn't act on it. Evidence: line 2492 writes `+0x04 = *(iVar4+0x28)`; nothing else in the function reads `+0x04`.
|
||||
- **High**: `FUN_0051bed0` is the PhysicsScript launcher and is called only from `FUN_005117a0` (PhysicsObject::RunScript), never from sky code.
|
||||
- **Medium**: Weather particles are camera-attached and sourced from a separate subsystem. Evidence: r12 deepdive assertion + absence of any sky-side PES spawn. The weather subsystem itself was not located in this hunt.
|
||||
- **Unknown**: Whether any retail-shipped region dat (Dereth, dungeons) actually populates `DefaultPesObjectId` on any SkyObject. Worth a dat scan: open every Region's SkyDesc and tally non-zero PesObjectIds. If the answer is "zero across all regions", the field is effectively dead data in retail and our "do nothing" port is 100% correct. If some regions populate it, there's a weather subsystem somewhere that reads it — but not from the render path.
|
||||
|
||||
---
|
||||
|
||||
## Pointers for future work
|
||||
|
||||
- **Locate the weather manager.** Grep `chunk_005*` and `chunk_004*` for calls to `FUN_0051bed0` with a parameter sourced from a SkyDesc/SkyObject field. If it exists, it'll show up as a single call in a function that also touches `DAT_0084247c` (region global).
|
||||
- **Scan retail dats for populated PesObjectIds.** `python tools/decompile_acclient.py` has no dat-scan helper, but the ACE `Region.cs` loader would parse every region — quick C# one-shot to tally non-zero Region.DayGroups[].SkyObjects[].DefaultPesObjectId values across all region IDs `0x13000000..0x1300FFFF`.
|
||||
- **Confirm weather is independent of sky rendering** by verifying that acdream's rain/snow (if we ever implement them) can render with sky renderer disabled and vice-versa. This is the retail behavior per the r12 writeup.
|
||||
166
docs/research/2026-04-24-lambert-brightness-split.md
Normal file
166
docs/research/2026-04-24-lambert-brightness-split.md
Normal 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.02–0.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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 1050–1820m
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"/>.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
279
src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
Normal file
279
src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
Normal 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 <= 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 (< 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<PhysicsScript></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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: 8–30 seconds between strikes.
|
||||
private const float StrikeIntervalMinS = 8f;
|
||||
private const float StrikeIntervalMaxS = 30f;
|
||||
|
||||
// Overcast-kind fog feels like ~40–150m 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),
|
||||
|
|
|
|||
210
tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
Normal file
210
tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 >> 16) & 0xff, G = (u >> 8) & 0xff,
|
||||
/// B = u & 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];
|
||||
|
|
|
|||
175
tools/SkyObjectInspect/Program.cs
Normal file
175
tools/SkyObjectInspect/Program.cs
Normal 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;
|
||||
}
|
||||
15
tools/SkyObjectInspect/SkyObjectInspect.csproj
Normal file
15
tools/SkyObjectInspect/SkyObjectInspect.csproj
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue