sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:
* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
(0..400m at midnight, up to 2400m during day) is calibrated for
terrain; sky meshes are authored at radii 1050-14271m which sits
past FogEnd universally, causing every sky pixel to saturate to
fogColor (dark navy). Stars, moon, dome texture all got
obliterated. The horizon-glow trade-off is noted in the shader
comment; research item to find retail's sky-specific fog range
later.
* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
vertex lighting saturates properly for bright keyframes. Retail's
FUN_0059da60 non-luminous path writes rep.Luminosity into
material.Emissive via the cache +0x3c slot; we were instead using
it as a post-fragment multiply which could only dim, never brighten.
Net effect: daytime clouds now render saturated white, dome dims
correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
and moon unchanged.
* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
(DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
pure ambient rather than getting an 8% sun floor.
New research / tooling (no runtime impact):
* docs/research/2026-04-24-lambert-brightness-split.md — retail's
ambient-brightness formula pinned from PE .rdata read + live
RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
where scale constant 0x0079a1e8 = 0.2f exactly.
* docs/research/2026-04-23-lightning-real.md — research note on the
dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
explicit PES-triggered flash SkyObjects with 5ms time windows).
* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
backwards).
* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
and the 0x0079a1e8 scale-factor readout.
* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
(A8R8G8B8 128x128 texture, 4% bright-pixel ratio).
* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
clouds decoded with proper alpha" type questions.
README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.
All 742 tests green.
This commit is contained in:
parent
889b235886
commit
1d54880213
12 changed files with 1217 additions and 43 deletions
169
README.md
169
README.md
|
|
@ -1,22 +1,173 @@
|
||||||
# acdream
|
# 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
|
## What works
|
||||||
- `references/` — local read-only reference material (ACE, ACViewer, WorldBuilder, DatReaderWriter, holtburger, retail AC install). Gitignored.
|
|
||||||
|
|
||||||
## 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"
|
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.
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
**Hunter:** Hunt Agent B (render-state signatures)
|
**Hunter:** Hunt Agent B (render-state signatures)
|
||||||
**Status:** SIGNIFICANT FINDINGS — but NOT a "celestial-body iteration draw loop"
|
**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
|
## TL;DR
|
||||||
|
|
||||||
The retail acclient does NOT appear to have a classical "sky dome + iterate
|
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
|
## 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).
|
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).
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
@ -45,14 +45,24 @@ layout(std140, binding = 1) uniform SceneLighting {
|
||||||
void main() {
|
void main() {
|
||||||
vec4 sampled = texture(uDiffuse, vTex);
|
vec4 sampled = texture(uDiffuse, vTex);
|
||||||
|
|
||||||
// Composite: texture × per-vertex lit × per-keyframe dim.
|
// Composite: texture × per-vertex lit.
|
||||||
vec3 rgb = sampled.rgb * vTint * uLuminosity;
|
// `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;
|
||||||
|
|
||||||
// Retail vertex fog: lerp(fogColor, scene, fogFactor). At distant
|
// Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED
|
||||||
// horizon dome vertices (distance > FOGEND) the sky saturates to
|
// 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m
|
||||||
// the keyframe's WorldFogColor — that's retail's horizon-glow
|
// while the midnight keyframe's FogEnd is only 400m. Every sky
|
||||||
// mechanism at dusk/dawn. See docs/research/2026-04-23-sky-fog.md.
|
// pixel was getting swamped to `uFogColor` (dark navy) — which
|
||||||
rgb = mix(uFogColor.rgb, rgb, vFogFactor);
|
// 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.
|
// Lightning additive bump — client-driven during storm flashes.
|
||||||
// NOTE: the exact retail mechanism for lightning visual is still
|
// NOTE: the exact retail mechanism for lightning visual is still
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,19 @@ out vec4 vRoad0;
|
||||||
out vec4 vRoad1;
|
out vec4 vRoad1;
|
||||||
flat out float vBaseTexIdx;
|
flat out float vBaseTexIdx;
|
||||||
|
|
||||||
// Retail's "ambient floor" constant from the decompiled AdjustPlanes
|
// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
|
||||||
// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
|
// chunk_00530000.c (AdjustPlanes). The decompile reads:
|
||||||
// this fraction of the sun color — NOT additive with ambient.
|
// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
|
||||||
const float MIN_FACTOR = 0.08;
|
// 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
|
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
|
||||||
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
|
// 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
|
else
|
||||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||||
|
|
||||||
// Per-submesh emissive (Surface.Luminosity FLOAT field —
|
// Emissive source: retail's FUN_0059da60 for non-luminous
|
||||||
// 1.0 for dome + sun + moon, 0.0 for clouds). The vertex
|
// surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive
|
||||||
// shader saturates the lighting math when emissive=1.0 so
|
// (via material cache +0x3c). This PROMOTES bright-keyframe
|
||||||
// self-illuminated meshes render at full texture brightness
|
// clouds into the self-lit term so the litColor saturates
|
||||||
// regardless of time of day; emissive=0.0 meshes get the
|
// and the texture renders at full brightness rather than
|
||||||
// full `ambient + diffuse × sun` tint (producing retail's
|
// being dimmed by a per-fragment multiply.
|
||||||
// purple night clouds / warm dusk clouds / pale noon clouds).
|
//
|
||||||
_shader.SetFloat("uEmissive", sub.SurfLuminosity);
|
// 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);
|
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,51 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
return h;
|
return h;
|
||||||
|
|
||||||
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
|
||||||
|
DumpAlphaHistogram(surfaceId, decoded);
|
||||||
h = UploadRgba8(decoded);
|
h = UploadRgba8(decoded);
|
||||||
_handlesBySurfaceId[surfaceId] = h;
|
_handlesBySurfaceId[surfaceId] = h;
|
||||||
return 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>
|
/// <summary>
|
||||||
/// Get or upload a texture for a Surface id but with its
|
/// Get or upload a texture for a Surface id but with its
|
||||||
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.
|
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// RetailTimeProbe — read the live retail acclient.exe process memory and
|
// RetailTimeProbe — read the live retail acclient.exe process memory and
|
||||||
// dump its TimeOfDay struct so we can compare against acdream's computed
|
// dump its TimeOfDay struct + sky-lighting global block so we can compare
|
||||||
// calendar values.
|
// against acdream's computed calendar / SkyKeyframe values.
|
||||||
//
|
//
|
||||||
// Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md
|
// Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md
|
||||||
// §4 and the daygroup-selection research):
|
// §4 and the daygroup-selection research):
|
||||||
|
|
@ -18,6 +18,30 @@
|
||||||
// TimeOfDay +0x68 int — DayOfYear
|
// TimeOfDay +0x68 int — DayOfYear
|
||||||
// TimeOfDay +0x6C int — SeasonIndex
|
// 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
|
// The acclient.exe referenced in the decompile has preferred image base
|
||||||
// 0x00400000 (standard Win32 default). If ASLR is enabled the actual
|
// 0x00400000 (standard Win32 default). If ASLR is enabled the actual
|
||||||
// load address will differ — we compute relative to Process.MainModule
|
// 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_DayOfYear = 0x68; // int
|
||||||
private const int Off_SeasonIndex = 0x6C; // 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.
|
// Process access rights needed: read memory + query info.
|
||||||
private const uint PROCESS_VM_READ = 0x0010u;
|
private const uint PROCESS_VM_READ = 0x0010u;
|
||||||
private const uint PROCESS_QUERY_INFORMATION = 0x0400u;
|
private const uint PROCESS_QUERY_INFORMATION = 0x0400u;
|
||||||
|
|
@ -55,22 +100,51 @@ internal static class Program
|
||||||
private static int Main(string[] args)
|
private static int Main(string[] args)
|
||||||
{
|
{
|
||||||
// Retail's process name is "acclient" (.exe stripped by Process API).
|
// Retail's process name is "acclient" (.exe stripped by Process API).
|
||||||
// Allow override from the command line just in case.
|
// args[0] = process name OR "pid=NNNN" to target a specific pid.
|
||||||
string processName = args.Length > 0 ? args[0] : "acclient";
|
string processName = "acclient";
|
||||||
|
int? requestedPid = null;
|
||||||
Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"...");
|
foreach (var a in args)
|
||||||
Process[] procs = Process.GetProcessesByName(processName);
|
|
||||||
if (procs.Length == 0)
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
if (a.StartsWith("pid=", StringComparison.OrdinalIgnoreCase) &&
|
||||||
$"no process named \"{processName}\" is running. Launch the retail AC client " +
|
int.TryParse(a.Substring(4), out var pidParsed))
|
||||||
"and log in to a character first, then re-run this probe.");
|
requestedPid = pidParsed;
|
||||||
return 2;
|
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(
|
Console.WriteLine(
|
||||||
$"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " +
|
$"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " +
|
||||||
$"mainmodule={target.MainModule?.FileName ?? "<null>"}");
|
$"mainmodule={target.MainModule?.FileName ?? "<null>"}");
|
||||||
|
|
@ -155,6 +229,9 @@ internal static class Program
|
||||||
double inferredTick = curDayStart + dayFraction * (curDayEnd - curDayStart);
|
double inferredTick = curDayStart + dayFraction * (curDayEnd - curDayStart);
|
||||||
Console.WriteLine($" inferred retail tick = {inferredTick:F3}");
|
Console.WriteLine($" inferred retail tick = {inferredTick:F3}");
|
||||||
Console.WriteLine($" retail LCG seed = year*secsPerDay + dayOfYear = {year}*{secsPerDayI}+{dayOfYear} = {(long)year * secsPerDayI + dayOfYear}");
|
Console.WriteLine($" retail LCG seed = year*secsPerDay + dayOfYear = {year}*{secsPerDayI}+{dayOfYear} = {(long)year * secsPerDayI + dayOfYear}");
|
||||||
|
|
||||||
|
// ---------------- Sky-lighting block dump ----------------
|
||||||
|
DumpSkyBlock(handle, moduleBase);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
finally
|
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)
|
private static uint ReadUInt32(IntPtr handle, IntPtr address)
|
||||||
{
|
{
|
||||||
byte[] buf = new byte[4];
|
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