diff --git a/README.md b/README.md index 3f2e1a1..47a5330 100644 --- a/README.md +++ b/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. diff --git a/docs/research/2026-04-23-lightning-real.md b/docs/research/2026-04-23-lightning-real.md new file mode 100644 index 0000000..8b5cc62 --- /dev/null +++ b/docs/research/2026-04-23-lightning-real.md @@ -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=)` 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. diff --git a/docs/research/2026-04-23-sky-decompile-hunt-B.md b/docs/research/2026-04-23-sky-decompile-hunt-B.md index 890e743..cda6556 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-B.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-B.md @@ -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 diff --git a/docs/research/2026-04-23-sky-decompile-hunt-C.md b/docs/research/2026-04-23-sky-decompile-hunt-C.md index 914e5ec..c879239 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-C.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-C.md @@ -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). diff --git a/docs/research/2026-04-24-lambert-brightness-split.md b/docs/research/2026-04-24-lambert-brightness-split.md new file mode 100644 index 0000000..dc4f562 --- /dev/null +++ b/docs/research/2026-04-24-lambert-brightness-split.md @@ -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. diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 95eaaeb..4ddfbde 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -45,14 +45,24 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Composite: texture × per-vertex lit × per-keyframe dim. - vec3 rgb = sampled.rgb * vTint * 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; - // Retail vertex fog: lerp(fogColor, scene, fogFactor). At distant - // horizon dome vertices (distance > FOGEND) the sky saturates to - // the keyframe's WorldFogColor — that's retail's horizon-glow - // mechanism at dusk/dawn. See docs/research/2026-04-23-sky-fog.md. - rgb = mix(uFogColor.rgb, rgb, vFogFactor); + // 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 diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index 433ab87..11e691d 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -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 diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 31ae73b..86c8d7f 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -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); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index c176fc8..e59a255 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -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; } + /// + /// 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 + /// ACDREAM_DUMP_SKY=1. Adds ~2ms per texture upload, negligible. + /// + 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)}"); + } + /// /// Get or upload a texture for a Surface id but with its /// OrigTextureId replaced by . diff --git a/tools/RetailTimeProbe/Program.cs b/tools/RetailTimeProbe/Program.cs index 3259357..ef79223 100644 --- a/tools/RetailTimeProbe/Program.cs +++ b/tools/RetailTimeProbe/Program.cs @@ -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 ?? ""}"); @@ -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("================================================================="); + } + + /// + /// 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. + /// + 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]; diff --git a/tools/SkyObjectInspect/Program.cs b/tools/SkyObjectInspect/Program.cs new file mode 100644 index 0000000..b0cce69 --- /dev/null +++ b/tools/SkyObjectInspect/Program.cs @@ -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(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(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(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(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(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(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; +} diff --git a/tools/SkyObjectInspect/SkyObjectInspect.csproj b/tools/SkyObjectInspect/SkyObjectInspect.csproj new file mode 100644 index 0000000..54b88ca --- /dev/null +++ b/tools/SkyObjectInspect/SkyObjectInspect.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + SkyObjectInspect + + + + + + +