From 8db7a9ec285cb2c8724941241e0f91b58387a8de Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 21:40:34 +0200 Subject: [PATCH] docs(research): sky/weather investigation handoff + diagnostic tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures everything learned from a long worktree iteration on the foreground-rain bug (ISSUES.md #1 / #26) plus a new star-rendering bug observed in the same area. The code work from that worktree (WeatherDispatcher, EmitterDescLoader.LoadFromDat, WeatherCellRenderer, GameWindow integration) was reverted because it didn't visibly fix the rain bug — but the research findings + diagnostic tools are durable and should not have to be rediscovered. What's added: - docs/research/2026-04-26-sky-investigation-handoff.md Comprehensive seed prompt for the next session. Covers: * Bug A: foreground rain (#26) — what's open, what's confirmed, what's been tried * Bug B: stars rendering as square in corner (NEW, user-observed) * 40-agent decomp scan findings — retail rain is NOT camera- particles, NOT server-driven, NOT screen-space; the mesh IS a hollow octagonal tube; only 5 weather GfxObjs in Dereth * Things ruled out by trial (envelope, scaling, unlit, depth- always alone, Setup loading) * Things to try next (depth+zfar combined, full render-state audit, frame ordering, star UV bug as easier first target) * Acceptance criteria for "done" - docs/research/2026-04-26-chorizite-pr-draft.md Upstream PR draft for Chorizite/DatReaderWriter. Five generated DBObj source files reference nonexistent enum values and are silently excluded from the NuGet build: ParticleEmitterInfo, Clothing, PaletteSet, DataIdMapper, DualDataIdMapper. Fix: delete the duplicates. Independent of the rain work — benefits the AC modding ecosystem broadly. - docs/research/2026-04-26-datreaderwriter-reference.md Developer reference for our DatReaderWriter usage. Version, types we consume, known broken types, thread-safety caveats, upgrade procedure, NuGet-vs-vendored decision matrix. - tools/PesChainAudit/ Recursive PES walker — given a 0x33xxxxxx script id, walks all CallPES references and dumps every hook + every referenced ParticleEmitter's parameters. Used to prove no weather PES emits rain particles. - tools/TextureDump/ Dumps texture pixel statistics (alpha histogram, brightness, max) and saves as PNG for visual inspection. - tools/WeatherEnumerator/ Enumerates every DayGroup in a Region, lists weather SkyObjects (Properties & 0x04), dumps GfxObj bounding boxes. - tools/WeatherSetupProbe/ Loads a Setup id, dumps each part's GfxObj + frame + scale + surface. Used to prove weather Setups are 5cm dummy carriers. Worktree feature/sky-fixes is being deleted in a follow-up step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../research/2026-04-26-chorizite-pr-draft.md | 82 +++++++ .../2026-04-26-datreaderwriter-reference.md | 158 ++++++++++++ .../2026-04-26-sky-investigation-handoff.md | 164 +++++++++++++ tools/PesChainAudit/PesChainAudit.csproj | 15 ++ tools/PesChainAudit/Program.cs | 133 ++++++++++ tools/TextureDump/Program.cs | 228 ++++++++++++++++++ tools/TextureDump/TextureDump.csproj | 16 ++ tools/WeatherEnumerator/Program.cs | 139 +++++++++++ .../WeatherEnumerator.csproj | 15 ++ tools/WeatherSetupProbe/Program.cs | 120 +++++++++ .../WeatherSetupProbe.csproj | 15 ++ 11 files changed, 1085 insertions(+) create mode 100644 docs/research/2026-04-26-chorizite-pr-draft.md create mode 100644 docs/research/2026-04-26-datreaderwriter-reference.md create mode 100644 docs/research/2026-04-26-sky-investigation-handoff.md create mode 100644 tools/PesChainAudit/PesChainAudit.csproj create mode 100644 tools/PesChainAudit/Program.cs create mode 100644 tools/TextureDump/Program.cs create mode 100644 tools/TextureDump/TextureDump.csproj create mode 100644 tools/WeatherEnumerator/Program.cs create mode 100644 tools/WeatherEnumerator/WeatherEnumerator.csproj create mode 100644 tools/WeatherSetupProbe/Program.cs create mode 100644 tools/WeatherSetupProbe/WeatherSetupProbe.csproj diff --git a/docs/research/2026-04-26-chorizite-pr-draft.md b/docs/research/2026-04-26-chorizite-pr-draft.md new file mode 100644 index 0000000..df0a11b --- /dev/null +++ b/docs/research/2026-04-26-chorizite-pr-draft.md @@ -0,0 +1,82 @@ +# Upstream issue draft — Chorizite/DatReaderWriter + +**Repo:** https://github.com/Chorizite/DatReaderWriter + +This draft is for filing as a GitHub issue (or a 1-line-fix PR) against +the Chorizite DatReaderWriter project. The duplicate generated file +fails to build against its own enum and shadows the correct +`ParticleEmitter` registration for the `0x32xxxxxx` ID range. + +--- + +## Title (under 80 chars) + +`Duplicate ParticleEmitterInfo.generated.cs references missing DBObjType enum value` + +## Summary + +`DatReaderWriter/Generated/DBObjs/` contains two generated files for +the same DB type (`DB_TYPE_PARTICLE_EMITTER`, ID range +`0x32000000–0x3200FFFF`): + +- `ParticleEmitter.generated.cs` — class `ParticleEmitter`, uses + `DBObjType.ParticleEmitter` (correct, matches the generated enum) +- `ParticleEmitterInfo.generated.cs` — class `ParticleEmitterInfo`, + uses `DBObjType.ParticleEmitterInfo` (does not exist in + `Generated/Enums/DBObjType.generated.cs`) + +The second file is byte-for-byte identical to the first apart from the +class name and the enum reference. Only `DBObjType.ParticleEmitter` is +emitted by the source generator (see line 179 of +`DBObjType.generated.cs`), so the duplicate fails to compile and also +registers a second `[DBObjType(..., 0x32000000, 0x3200FFFF, ...)]` +attribute for the same ID range, which collides at runtime in +`PortalDatabase.Get(uint id)`. + +## Reproduction + +1. Clone `Chorizite/DatReaderWriter` at master. +2. `dotnet build DatReaderWriter.sln` +3. Build fails in `Generated/DBObjs/ParticleEmitterInfo.generated.cs` + on the `DBObjType.ParticleEmitterInfo` reference (`CS0117: 'DBObjType' does not contain a definition for 'ParticleEmitterInfo'`). + +If the file is hand-edited to compile (e.g. by switching the enum to +`ParticleEmitter`), two classes are then registered for the same ID +range `0x32000000–0x3200FFFF` and `Get(id)` / +`Get(id)` ambiguously dispatch. + +## Expected behavior + +A single generated class for `DB_TYPE_PARTICLE_EMITTER`. The +`PortalDatabase.generated.cs` reader already exposes only +`GetParticleEmitter(uint id)` returning `ParticleEmitter?`, so +`ParticleEmitter` is the canonical name. + +## Actual behavior + +`ParticleEmitterInfo.generated.cs` exists and breaks the build. It +appears to be a stale artifact from an earlier pass of the source +generator when the type was renamed `ParticleEmitterInfo → ParticleEmitter`. + +## Proposed fix (one line) + +Delete `DatReaderWriter/Generated/DBObjs/ParticleEmitterInfo.generated.cs`. +If the source generator regenerates it, the generator's type-name map +needs the `ParticleEmitterInfo` entry removed too. + +## Why this matters to downstream consumers + +Downstream consumers (e.g. acdream, an open-source modern AC client +that vendors DatReaderWriter) cannot build the library as-is — we +have to manually delete the duplicate file in our vendored copy on +every update. Anyone trying to load `0x32xxxxxx` records and reaching +for `ParticleEmitterInfo` (the historical / decompiled-client name) +hits a broken type instead of being directed at the renamed +`ParticleEmitter`. A clean upstream fix removes the per-consumer +patch. + +## Environment + +- Commit: master @ HEAD as of 2026-04-26 +- .NET SDK: 10.0 +- OS: Windows 11 diff --git a/docs/research/2026-04-26-datreaderwriter-reference.md b/docs/research/2026-04-26-datreaderwriter-reference.md new file mode 100644 index 0000000..6b8f33f --- /dev/null +++ b/docs/research/2026-04-26-datreaderwriter-reference.md @@ -0,0 +1,158 @@ +# DatReaderWriter (DRW) — developer reference + +Status as of **2026-04-26**. Source for "what's safe to call, what's +broken, how to upgrade." Update this doc when DRW version bumps or when +we discover a new safety/coverage gap. + +## 1. Current version + +- **Package:** `Chorizite.DatReaderWriter` +- **Version:** `2.1.7` (NuGet) +- Pinned in `src/AcDream.Core/AcDream.Core.csproj` and + `src/AcDream.Cli/AcDream.Cli.csproj`. App + Core both transitively + use that one version. +- Some `tools/*` (WeatherSetupProbe, WeatherEnumerator, SkyObjectInspect, + dump-keymap, PesChainAudit) `ProjectReference` the vendored repo + directly instead of NuGet — see §7. + +## 2. Source repo + +- **Origin:** https://github.com/Chorizite/DatReaderWriter +- **Vendored at:** `references/DatReaderWriter/` (HEAD `63e84b6 "Bump + version"` — no git tag matching the NuGet version; the repo doesn't + tag releases, ship version is `GitVersion.yml`-driven). +- **License:** MIT, © 2024 ACClientLib (`references/DatReaderWriter/LICENSE.txt`). +- **Targets:** `net8.0`, `netstandard2.0`, `net48`. We consume from + `net10.0` projects; the `net8.0` TFM resolves cleanly via netstandard + forward-compat. +- **Project layout:** `DatReaderWriter/` (library), `…SourceGenerator/` + (Roslyn generator emits packing code), `…Tests/`, `…Benchmarks/`. + +## 3. Types we use + +Top-level entry points: + +- `DatReaderWriter.DatCollection` — the four-database container + (`Portal`, `Cell`, `HighRes`, `Local`). +- `DatReaderWriter.Options.DatCollectionOptions` (caching mode, paths). +- `DatCollection.Get(id)` / `Portal.Get(id)` — generic typed read. +- `DatCollection.GetAllIdsOfType()` — used by CLI enumerators. + +`DatReaderWriter.DBObjs.*` consumed in `src/`: + +- `LandBlock`, `LandBlockInfo`, `EnvCell`, `Region` +- `GfxObj`, `Setup`, `Surface`, `SurfaceTexture` (via SurfaceDecoder) +- `Animation`, `MotionTable` +- `ParticleEmitter`, `PhysicsScript` +- `Environment` (note: collides with `System.Environment`, must be FQN + in `AcDream.Cli/Program.cs`) +- `Wave` / sound DBObjs (via `DatSoundCache`, `SoundCookbook`) + +`DatReaderWriter.Types.*` consumed: + +- `AnimationFrame`, `MotionData` +- `Polygon`, `VertexArray`, `Sphere` (BSPQuery, collision) +- `SoundEntry`, `AnimationHook`, `CreateParticleHook` +- `BSPNode` / `BSPTree` / `PhysicsBSPNode` / `CellBSPNode` / + `DrawingBSPNode` (BSP traversal in `BSPQuery`, `PhysicsDataCache`) + +`DatReaderWriter.Enums.*` consumed: + +- `MotionCommand` (aliased `DRWMotionCommand` in MotionCommandResolver) +- `Sound` (aliased `DRWSound`) +- `StipplingType`, plus various surface/material flags + +## 4. Known broken / missing types + +- **`RenderMaterial`** — not implemented. Per upstream README "Known + Issues." We do not consume it. +- **`LayoutDesc`** — README marks it "supported but structure will need + cleanup." Treat fields as unstable across versions. We do not depend + on it directly. +- **`ParticleEmitterInfo`** — DRW exposes the **`ParticleEmitter`** + DBObj only. There is no separate `ParticleEmitterInfo` type. Our + `Vfx/VfxModel.cs` comment that mentions "ParticleEmitterInfo dat" is + conceptual (matches the retail header struct name); the actual lookup + in `EmitterDescLoader.cs` is `dats.Portal.Get(id)` + with a `using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;` + alias because we have our own `ParticleEmitter` runtime class in + `AcDream.Core.Vfx`. +- **Anything not in `DBObjs/`** — only 5 DBObj files exist + (`ActionMap`, `DBProperties`, `Iteration`, `LandBlock`, + `MasterProperty`) at the top level of that folder; the rest (GfxObj, + Setup, etc.) live elsewhere in the source tree. If a type doesn't + resolve, search the DRW source — don't assume it's missing. + +## 5. Unsafe patterns + +**`DatCollection` is NOT thread-safe.** `DatBinReader` holds a buffer +position field per database; concurrent `Get` calls from two threads +corrupt each other and produce silently half-populated payloads +(half-empty `LandBlock.Height[]` in our case). See +`memory/feedback_phase_a1_hotfix_saga.md` rule #2 — Phase A.1 burned +~3 hotfixes mis-diagnosing this as something else. + +**Rules:** + +1. All `DatCollection.Get()` calls run on the render thread. +2. `LandblockStreamer` runs synchronously (see header comment, lines + 17–32 of `src/AcDream.App/Streaming/LandblockStreamer.cs`). The + worker-thread design was reverted; the channel-based outbox is + retained so the moment a thread-safe wrapper exists we can swap + loaders back to async without touching call sites. +3. Don't claim "DRW is thread-safe per its docs." There are no such + docs. The README says nothing about concurrency. +4. The async surface area (`*Async` methods) is for I/O, not + concurrent reads — calling them from two threads is still unsafe. + +**Caching:** `DatCollectionOptions` caching mode is `OnDemand`. Don't +flip to `None` without re-benchmarking — terrain mesh build hits the +same `LandBlock` repeatedly. + +## 6. How to upgrade + +1. Bump `Version="2.1.7"` in `src/AcDream.Core/AcDream.Core.csproj` and + `src/AcDream.Cli/AcDream.Cli.csproj` (and any `tools/*` using the + NuGet package, e.g. `TextureDump`). +2. Pull `references/DatReaderWriter/` to the matching commit so the + vendored copy stays in sync (used by tools that `ProjectReference` + it directly + as a search target for "what's in DRW today"). +3. `dotnet restore && dotnet build` from repo root. +4. Run the full test suite (`dotnet test`). Watch for compile breaks + on type renames (DRW has historically renamed `DBObj` types between + minor versions). +5. Smoke-test live: launch against the local ACE server and walk + Holtburg → Foundry. Watch for half-loaded landblocks (missing + heights / null surfaces) — that's the canonical "thread-safety + regression slipped in" signature. + +**Risks per area:** + +- **DBObj field renames** — DRW source-generates packing; field renames + break every consumer. Grep before bumping. +- **Enum value reordering** — `MotionCommand`, `Sound`, + `StipplingType` are enum-by-name in our code, so adds are safe but + removals will break. +- **BSP type changes** — `BSPNode` family is consumed in `BSPQuery.cs` + (collision). Any signature change there is a high-risk diff that + needs the conformance sweep re-run. + +## 7. NuGet vs vendored + +- **Use NuGet** (`Chorizite.DatReaderWriter` package) for everything + shipped in `src/` and any `tools/` that doesn't need DRW internals. + This is the default. Currently `AcDream.Core`, `AcDream.Cli`, and + `tools/TextureDump`. +- **Vendor (`ProjectReference` to `references/DatReaderWriter/…`)** when: + - The tool needs a DRW type or method that isn't `public` in the + NuGet build (some helpers are `internal`). + - You're prototyping a DRW patch you intend to upstream — edit in + place, build, validate, then PR upstream. + - Diagnostic / probe tools (`WeatherSetupProbe`, `WeatherEnumerator`, + `SkyObjectInspect`, `dump-keymap`, `PesChainAudit`) where iteration + speed against DRW source matters more than ship discipline. +- **Never mix** within one assembly. A project either NuGet-references + DRW or `ProjectReference`s the vendored copy. Both produces type- + identity errors at the boundary. +- **Production code stays on NuGet.** When a vendored tool proves a + DRW change is needed, upstream it, wait for a release, and bump §6. diff --git a/docs/research/2026-04-26-sky-investigation-handoff.md b/docs/research/2026-04-26-sky-investigation-handoff.md new file mode 100644 index 0000000..63f05f3 --- /dev/null +++ b/docs/research/2026-04-26-sky-investigation-handoff.md @@ -0,0 +1,164 @@ +# Sky / Weather Investigation — Handoff for Next Session + +**Date:** 2026-04-26 +**Author:** Claude (Opus 4.7) closing out a long worktree +**Purpose:** seed prompt for a fresh session that resumes investigation of two sky-related visual bugs without re-deriving everything we learned the hard way. + +--- + +## What you're picking up + +Two open visual bugs in the sky/weather pipeline, both observed against a live ACE server in Holtburg: + +### Bug A — Foreground rain missing (ISSUES.md #26) + +In retail, when the active DayGroup is "Rainy", you see rain everywhere — in the air between the camera and your character, on the horizon, overhead. Volumetric appearance. + +In acdream as of `main` (post-revert), you see no falling rain at all. Even the previous (rolled-back) work that rendered the rain mesh in world space produced only a "discrete cylinder of rain" around the player, not the volumetric look retail has. + +### Bug B — Stars appear as a square in one corner instead of stretched across the sky + +User-observed during a recent night-time launch. The star sky-object's texture appears as a small square in one corner of the geometry instead of being mapped across the full sky dome / star plane. Suspected: UV-mapping bug, or wrong texture wrap mode (CLAMP vs REPEAT), or wrong geometry for the star object. + +**Both bugs may share a root cause** in how we handle sky GfxObj UVs / texture sampling. Worth investigating B first because it might be simpler AND might reveal something that explains A too. + +--- + +## What's on `main` right now + +- The original `SkyRenderer` (camera-anchored sky pass) — unchanged. +- The original hardcoded camera-attached rain emitter (`BuildRainDesc`/`BuildSnowDesc` + `UpdateWeatherParticles`) in `GameWindow.cs` — still uses the broken `StartAlpha = 0.3 → EndAlpha = 0` linear fade. This is the alpha-fade bug from the original Issue #1 (rain fades to invisible exactly at ground level). +- ISSUES.md #1 is OPEN and #26 hasn't been filed yet on main (it lived only on the deleted `feature/sky-fixes` branch). + +**This handoff doc captures everything #26 contained**, so don't worry about losing it. + +--- + +## What we already learned (40-agent decomp scan, 2026-04-26) + +**Confirmed truths about retail rain:** + +1. **Retail rain is NOT camera-attached particles.** Across the entire 1.4M-line decomp at `docs/research/named-retail/acclient_2013_pseudo_c.txt`, only TWO callers of `ParticleManager::CreateParticleEmitter` exist — both are animation-hook executors (`CreateParticleHook::Execute`, `CreateBlockingParticleHook::Execute`). No camera-attached rain spawner exists in retail. + +2. **Retail rain is NOT server-driven.** No inbound network message triggers rain. ACE doesn't broadcast it. Weather is entirely client-side, dat-driven from `Region.SkyInfo.DayGroups[i].SkyObjects[]`. `EnvironChangeType` (fog colors + 6 thunder sound enums) is the only weather-related broadcast and it doesn't trigger visuals. + +3. **Retail rain is NOT screen-space.** No fullscreen quad, no orthographic projection, no render-target swap, no post-process. Verified via grep for `D3DXMatrixOrtho`, `SetRenderTarget`, `FullscreenQuad`, etc. + +4. **The mesh IS exactly a hollow octagonal tube.** Vertex dump of `0x01004C42` and `0x01004C44`: + - 24 verts (some duplicated for UV seams) + - 16 triangles forming 8 vertical wall quads + - Octagonal corners at radius 113m around center + - 815m tall (z=0.11 to z=814.90) + - **No top/bottom caps** — purely walls + - Surface 0x080000C5 = `Base1Image | Translucent | Alpha | Additive`, SurfTranslucency=0.5 + +5. **Only 5 unique weather GfxObjs exist in all of Dereth's 20 DayGroups:** + - `0x01004C44` — hollow octagonal tube, TexVel=(0.02, -1.7) — direct GfxObj + - `0x01004C42` — hollow octagonal tube, TexVel=(0.02, -2.0) — direct GfxObj + - `0x02000BA6`, `0x02000588`, `0x02000589` — Setups, each containing a SHARED single 5cm dummy GfxObj (`0x010001EC`, 3 verts, 1 polygon) carrying a flash PES via DefaultScript + +6. **The PES referenced by weather Setups produces ONLY flash + sound effects.** Recursive walk via `tools/PesChainAudit/` confirmed: across all 5 weather PESes (`0x33000453`, `0x33000428`, `0x3300042C`, `0x33000429`, `0x3300042D`), there is exactly ONE CreateParticleHook anywhere in the chain, and it spawns `ParticleEmitter 0x320002C2` — a 10ms-lifetime, max-2-particle, zero-extent flash. The other 4 PESes are pure SoundTweaked + CallPES recursion loops (rolling thunder cadence). Zero rain particles. + +7. **The rain streak texture is `0x050016A6`** — 256x256, R8G8B8 (no alpha), 99.4% black, sparse bright streaks. Engineered for additive blending. Not the texture itself broken — works as designed for an additive sparse-streak overlay. + +8. **Retail uses `DEPTHTEST_ALWAYS` and `zfar*4`** for the weather pass, set at decomp `0x00507063` and `0x00507055` in `GameSky::Draw(arg2=1)`. We tried `DEPTHTEST_ALWAYS` alone — it made the visual WORSE (rain mesh now covers everything as a wall over scene geometry). Whatever combination retail uses, it's not just depth-always. + +**Things we ruled out by trying:** + +- Camera-attached envelope spawn (made up r12-deepdive params; appeared as small dots near camera, not retail-like) +- Scaling the rain mesh down (turns it into an obvious cylinder around player) +- Unlit additive shader (no perceptible change) +- DEPTHTEST_ALWAYS alone (made it look worse) +- Setup loading for weather Setups (Setup parts are 5cm dummy quads, not visual) + +**Things we did NOT try and might be the answer:** + +- **Retail's frame ordering** — maybe the weather pass renders BEFORE the scene, not after. Worth checking the main render loop in retail (probably around `LScape::draw` or `SmartBox::Draw`). +- **Per-surface render state on the additive surface** — maybe retail enables D3DRS_ALPHATESTENABLE + D3DRS_ALPHAREF for the rain Surface specifically, discarding pure-black texels. +- **Texture sampling differences** — mipmap settings, bilinear vs nearest, anisotropy. Retail's view of the cylinder wall at 113m goes through mipmap selection that might dramatically affect appearance. +- **The DEPTHTEST_ALWAYS + zfar*4 combination together** — we only tried depth alone. Maybe the zfar change is what makes it work. +- **Multiple instances of the same mesh** — retail might spawn the rain CPhysicsObj at SEVERAL anchor positions (not just the player), creating overlapping coverage. Worth re-checking `GameSky::CreateDeletePhysicsObjects` for loop bounds. +- **The actual texture pixel content displayed in retail vs ours** — `tools/TextureDump/` already dumps the texture as PNG. Worth visually comparing what retail's rain looks like to verify the texture is correct. + +--- + +## Probe tools available (already on main, in `tools/`) + +These are one-shot diagnostic console apps. Each builds standalone with `Chorizite.DatReaderWriter` NuGet + an env var pointing at the dat dir. + +- **`tools/PesChainAudit/`** — recursive PES walker. Given a starting `0x33xxxxxx` script id, walks all CallPES references and dumps every hook + every referenced ParticleEmitter's params. Used to prove no weather PES has rain particles. +- **`tools/TextureDump/`** — dumps texture pixel stats (alpha histogram, brightness mean/stddev, max) and saves as PNG for visual inspection. +- **`tools/WeatherEnumerator/`** — enumerates all DayGroups in a Region, lists every weather SkyObject (Properties & 0x04), dumps GfxObj bounding boxes. +- **`tools/WeatherSetupProbe/`** — loads a Setup id, dumps every part's GfxObj, frame, scale, and surface info. Used to prove weather Setups are 5cm dummy quads. +- **`tools/SetupProbe/`** — already in main (similar to WeatherSetupProbe, slightly different focus). + +Run from `tools//` with `dotnet run -c Release`. Set `ACDREAM_DAT_DIR` if needed (defaults to `%USERPROFILE%\Documents\Asheron's Call`). + +--- + +## Other useful research artifacts on main + +- **`docs/research/2026-04-26-chorizite-pr-draft.md`** — upstream PR draft for the Chorizite/DatReaderWriter project. Five DBObj source files in their `Generated/` directory are stale duplicates that reference nonexistent enum values, causing them to be silently excluded from the published NuGet (`ParticleEmitterInfo`, `Clothing`, `PaletteSet`, `DataIdMapper`, `DualDataIdMapper`). The fix is to delete those files. **You can file this PR independent of the rain investigation — it benefits the whole AC modding ecosystem.** + +- **`docs/research/2026-04-26-datreaderwriter-reference.md`** — DRW dev reference. Versioning, types we use, broken types catalog, upgrade procedure, NuGet vs vendored decision matrix. + +- **`docs/research/named-retail/`** — the PDB-named retail decomp. Always grep here FIRST for any AC behavior question. Particularly relevant for sky/weather: + - `GameSky::Draw` at `0x00506ff0` + - `GameSky::CreateDeletePhysicsObjects` at `0x005073c0` + - `GameSky::MakeObject` at `0x00506ee0` + - `GameSky::UpdatePosition` at `0x00506dd0` + - `LScape::weather_enabled` at `0x0081cbe9` + - `SmartBox::EnableWeather` at `0x00451dd0` + +- **`docs/research/deepdives/r12-weather-daynight.md`** — pre-decomp deep dive on weather mechanism. **Several claims in r12 turned out to be incorrect** (specifically the "rain is a camera-attached ParticleEmitter" claim was disproven by the 40-agent scan). Read it for context but don't trust the rain-mechanism details. + +- **`docs/research/2026-04-23-lightning-real.md`** — corrected understanding of lightning (server-driven via opcode `0xF754 PlayScript`, not sky-walked). Relevant if you also work on Issue #2. + +--- + +## Suggested next investigation paths (priority-ranked) + +1. **Investigate Bug B first (the star bug)** — much more concrete (UV mapping, texture wrap, geometry — all easily inspectable). May reveal the same kind of issue that affects rain. Specifically: + - Identify which SkyObject in DayGroup[0] (Sunny / clear night) renders the stars + - Dump that GfxObj's vertices + UVs via the existing `tools/SetupProbe/` or extend `WeatherEnumerator/` + - Compare authored UV range to expected sky-dome coverage + - Check texture wrap mode in `SkyRenderer.cs` (we set REPEAT for scrolling, CLAMP_TO_EDGE otherwise — stars probably need REPEAT or full UV remapping) + +2. **Re-grep retail decomp for the FULL render-state setup around the weather pass.** The agents found DEPTHTEST_ALWAYS but maybe missed alpha test, blend mode, fog state, sample state, etc. Specifically dump the full `GameSky::Draw(arg2=1)` body and verify EVERY D3D state call we should be replicating. + +3. **Try DEPTHTEST_ALWAYS + zfar*4 TOGETHER.** We only tried depth alone. The zfar change may matter for how the cylinder's far wall projects. + +4. **Investigate retail's frame ordering.** Where in retail's main render loop does `GameSky::Draw(arg2=1)` actually fire? Before or after scene meshes? If it's BEFORE, the depth test gives a totally different result than rendering after. + +5. **File the Chorizite PR.** Independent of rain. Easy contribution. + +--- + +## What "done" looks like + +For Bug A (rain): standing in Holtburg during a Rainy DayGroup with `ACDREAM_DAY_GROUP=7`, rain visible IN the air between camera and character, not just on horizon. Looking up still matches retail. Walking forward, rain follows naturally. + +For Bug B (stars): standing in Holtburg at night (`ACDREAM_DAY_GROUP=`), stars visible as a full sky covering, NOT a small square in one corner. + +--- + +## Suggested first prompt for the next session + +``` +Read docs/research/2026-04-26-sky-investigation-handoff.md. + +Tackle Bug B (stars rendering as a square in one corner) first +since it's more concrete. Use tools/WeatherEnumerator/ as a model +for a new probe that dumps the star SkyObject's geometry and UVs. +Iterate to a fix, verify visually, commit. + +Once stars work, return to Bug A (rain) using the priority-ranked +investigation paths in §"Suggested next investigation paths" of +the handoff doc. The 40-agent findings have ruled out enough that +the next likely answer is in retail's full render-state setup +around the weather pass — specifically things the agents may have +missed beyond DEPTHTEST_ALWAYS. + +Use a worktree per CLAUDE.md, and don't ask "should I continue" +between sub-steps. +``` diff --git a/tools/PesChainAudit/PesChainAudit.csproj b/tools/PesChainAudit/PesChainAudit.csproj new file mode 100644 index 0000000..df9c30f --- /dev/null +++ b/tools/PesChainAudit/PesChainAudit.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + PesChainAudit + + + + + + + diff --git a/tools/PesChainAudit/Program.cs b/tools/PesChainAudit/Program.cs new file mode 100644 index 0000000..3c435ff --- /dev/null +++ b/tools/PesChainAudit/Program.cs @@ -0,0 +1,133 @@ +// PesChainAudit — recursively walk weather PES chains, dump every hook, +// and decode every CreateParticleHook target ParticleEmitter. +using System; +using System.Collections.Generic; +using System.IO; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +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); + +uint[] roots = { 0x33000453u, 0x33000428u, 0x3300042Cu, 0x33000429u, 0x3300042Du }; + +var rainCandidates = new List<(uint pesRoot, uint emitterId, double life, float maxOff, System.Numerics.Vector3 a)>(); + +foreach (var root in roots) +{ + Console.WriteLine(); + Console.WriteLine($"############ ROOT PES 0x{root:X8} ############"); + var visited = new HashSet(); + WalkPes(root, depth: 0, rootForReport: root); + + void WalkPes(uint pesId, int depth, uint rootForReport) + { + string pad = new string(' ', depth * 2); + if (!visited.Add(pesId)) + { + Console.WriteLine($"{pad}PES 0x{pesId:X8} (already visited — cycle skip)"); + return; + } + if (!dats.TryGet(pesId, out var ps) || ps is null) + { + Console.WriteLine($"{pad}PES 0x{pesId:X8} NOT FOUND"); + return; + } + Console.WriteLine($"{pad}PES 0x{pesId:X8} hooks={ps.ScriptData.Count}"); + for (int i = 0; i < ps.ScriptData.Count; i++) + { + var entry = ps.ScriptData[i]; + var hook = entry.Hook; + string head = $"{pad} [{i}] t={entry.StartTime:F3}s {hook.HookType}"; + switch (hook) + { + case CallPESHook call: + Console.WriteLine($"{head} -> PES=0x{call.PES:X8} pause={call.Pause:F3}"); + WalkPes(call.PES, depth + 2, rootForReport); + break; + case CreateParticleHook cp: + uint eid = (uint)cp.EmitterInfoId; + Console.WriteLine($"{head} EmitterInfoId=0x{eid:X8} PartIdx={cp.PartIndex} EmitterId={cp.EmitterId}"); + DumpEmitter(eid, pad + " ", rootForReport); + break; + case CreateBlockingParticleHook _: + Console.WriteLine($"{head} (no payload — looks at next hook)"); + break; + case SoundHook sh: + Console.WriteLine($"{head} sound=0x{(uint)sh.Id:X8}"); + break; + case SoundTableHook stb: + Console.WriteLine($"{head} soundType={stb.SoundType}"); + break; + case SetLightHook _: + Console.WriteLine($"{head} (set-light)"); + break; + case ScaleHook sc: + Console.WriteLine($"{head} end={sc.End} time={sc.Time}"); + break; + case TransparentHook th: + Console.WriteLine($"{head} start={th.Start} end={th.End} time={th.Time}"); + break; + case DefaultScriptHook _: + case DefaultScriptPartHook _: + case AnimationDoneHook _: + case NoDrawHook _: + Console.WriteLine(head); + break; + default: + Console.WriteLine($"{head} (no decoder)"); + break; + } + } + } + + void DumpEmitter(uint emitterInfoId, string pad, uint rootForReport) + { + if (!dats.TryGet(emitterInfoId, out var pe) || pe is null) + { + Console.WriteLine($"{pad}ParticleEmitter 0x{emitterInfoId:X8} NOT FOUND"); + return; + } + Console.WriteLine($"{pad}ParticleEmitter 0x{emitterInfoId:X8}"); + Console.WriteLine($"{pad} EmitterType={pe.EmitterType} ParticleType={pe.ParticleType}"); + Console.WriteLine($"{pad} Gfx=0x{(uint)pe.GfxObjId:X8} HwGfx=0x{(uint)pe.HwGfxObjId:X8}"); + Console.WriteLine($"{pad} Birthrate={pe.Birthrate:F4}s MaxParticles={pe.MaxParticles} Initial={pe.InitialParticles} Total={pe.TotalParticles} TotalSeconds={pe.TotalSeconds:F3}"); + Console.WriteLine($"{pad} Lifespan={pe.Lifespan:F3}s ±{pe.LifespanRand:F3}"); + Console.WriteLine($"{pad} OffsetDir=({pe.OffsetDir.X:F3},{pe.OffsetDir.Y:F3},{pe.OffsetDir.Z:F3}) MinOffset={pe.MinOffset:F2} MaxOffset={pe.MaxOffset:F2}"); + Console.WriteLine($"{pad} A=({pe.A.X:F3},{pe.A.Y:F3},{pe.A.Z:F3}) MinA={pe.MinA:F3} MaxA={pe.MaxA:F3}"); + Console.WriteLine($"{pad} B=({pe.B.X:F3},{pe.B.Y:F3},{pe.B.Z:F3}) MinB={pe.MinB:F3} MaxB={pe.MaxB:F3}"); + Console.WriteLine($"{pad} C=({pe.C.X:F3},{pe.C.Y:F3},{pe.C.Z:F3}) MinC={pe.MinC:F3} MaxC={pe.MaxC:F3}"); + Console.WriteLine($"{pad} Scale start={pe.StartScale:F2} final={pe.FinalScale:F2} rand={pe.ScaleRand:F2}"); + Console.WriteLine($"{pad} Trans start={pe.StartTrans:F2} final={pe.FinalTrans:F2} rand={pe.TransRand:F2} ParentLocal={pe.IsParentLocal}"); + bool rainLike = pe.Lifespan > 0.5 + && (pe.OffsetDir.Z > 1.0f || pe.MaxOffset > 5.0f || pe.A.Z < -0.1f); + if (pe.Lifespan > 0.5) + Console.WriteLine($"{pad} >>> Lifespan>0.5s — possibly rain-like (downward A.Z={pe.A.Z:F2}, MaxOffset={pe.MaxOffset:F2})"); + if (rainLike) + rainCandidates.Add((rootForReport, emitterInfoId, pe.Lifespan, pe.MaxOffset, pe.A)); + } +} + +Console.WriteLine(); +Console.WriteLine("################ RAIN-LIKE EMITTER SUMMARY ################"); +if (rainCandidates.Count == 0) +{ + Console.WriteLine("NONE — no emitter discovered (any depth) has Lifespan>0.5s with downward A or large MaxOffset."); + Console.WriteLine("=> All 5 weather PESes are flash/sound only; no hidden rain emitter exists in these chains."); +} +else +{ + foreach (var c in rainCandidates) + { + Console.WriteLine($" rootPES=0x{c.pesRoot:X8} emitter=0x{c.emitterId:X8} life={c.life:F2}s maxOff={c.maxOff:F2} A=({c.a.X:F2},{c.a.Y:F2},{c.a.Z:F2})"); + } +} + +return 0; diff --git a/tools/TextureDump/Program.cs b/tools/TextureDump/Program.cs new file mode 100644 index 0000000..86cf43f --- /dev/null +++ b/tools/TextureDump/Program.cs @@ -0,0 +1,228 @@ +// TextureDump — probe rain-streak textures (0x050016A4..0x050016A8) for +// the volumetric rain density-trick decision. +using System; +using System.IO; +using System.IO.Compression; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +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); + +string outDir = Path.Combine(AppContext.BaseDirectory, "out"); +Directory.CreateDirectory(outDir); +Console.WriteLine($"outDir = {outDir}"); + +// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen +// to 0x050016A0..0x050016AF to catch any related precip textures. +var idList = new System.Collections.Generic.List(); +for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i); +uint[] ids = idList.ToArray(); + +(uint id, double densityFraction)? best = null; + +foreach (var id in ids) +{ + Console.WriteLine(); + Console.WriteLine($"=== 0x{id:X8} ==="); + + RenderSurface? rs = null; + // SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx). + if (dats.TryGet(id, out var st) && st is not null && st.Textures.Count > 0) + { + uint rsid = (uint)st.Textures[0]; + Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}"); + if (dats.TryGet(rsid, out var inner) && inner is not null) + rs = inner; + } + else if (dats.TryGet(id, out var direct) && direct is not null) + { + rs = direct; + } + if (rs is null) + { + Console.WriteLine(" (not a SurfaceTexture or RenderSurface, or not found)"); + continue; + } + + Console.WriteLine($" Dimensions: {rs.Width} x {rs.Height}"); + Console.WriteLine($" PixelFormat: {rs.Format} (0x{(uint)rs.Format:X8})"); + Console.WriteLine($" SourceData bytes: {rs.SourceData?.Length ?? 0}"); + Console.WriteLine($" DefaultPaletteId: 0x{rs.DefaultPaletteId:X8}"); + + Palette? palette = null; + if (rs.DefaultPaletteId != 0) + { + if (dats.TryGet(rs.DefaultPaletteId, out var pal) && pal is not null) + { + palette = pal; + Console.WriteLine($" Palette colors: {pal.Colors.Count}"); + } + } + + var dec = SurfaceDecoder.DecodeRenderSurface(rs, palette); + if (dec.Rgba8 is null || dec.Rgba8.Length == 0) + { + Console.WriteLine(" DECODE FAILED."); + continue; + } + + // Stats: alpha histogram (8 buckets), brightness mean/stddev, bright count. + int n = dec.Width * dec.Height; + int[] alphaBuckets = new int[8]; // 0..31, 32..63, ..., 224..255 + long sumB = 0; + long sumBSq = 0; + int brightAlpha = 0; // alpha > 128 + int brightLum = 0; // brightness > 200 + int litLum = 0; // brightness > 16 (any visible pixel) + int midLum = 0; // brightness > 64 + int nonZeroAlpha = 0; + int maxLum = 0; + for (int i = 0; i < n; i++) + { + byte r = dec.Rgba8[i * 4 + 0]; + byte g = dec.Rgba8[i * 4 + 1]; + byte b = dec.Rgba8[i * 4 + 2]; + byte a = dec.Rgba8[i * 4 + 3]; + int lum = (r + g + b) / 3; + sumB += lum; + sumBSq += (long)lum * lum; + alphaBuckets[Math.Min(7, a / 32)]++; + if (a > 128) brightAlpha++; + if (lum > 200) brightLum++; + if (lum > 64) midLum++; + if (lum > 16) litLum++; + if (a > 0) nonZeroAlpha++; + if (lum > maxLum) maxLum = lum; + } + double mean = (double)sumB / n; + double variance = ((double)sumBSq / n) - mean * mean; + double stddev = Math.Sqrt(Math.Max(0, variance)); + + Console.WriteLine($" Pixels: {n}"); + Console.Write(" Alpha histogram (8 buckets, 0->255):"); + for (int b = 0; b < 8; b++) Console.Write($" {alphaBuckets[b]}"); + Console.WriteLine(); + Console.WriteLine($" Brightness mean = {mean:F2}, stddev = {stddev:F2}, max = {maxLum}"); + Console.WriteLine($" Lit pixels: lum>16 = {litLum} ({100.0 * litLum / n:F3}%) lum>64 = {midLum} ({100.0 * midLum / n:F3}%) lum>200 = {brightLum} ({100.0 * brightLum / n:F3}%)"); + Console.WriteLine($" alpha>128 = {brightAlpha} ({100.0 * brightAlpha / n:F2}%) nonZeroAlpha = {nonZeroAlpha} ({100.0 * nonZeroAlpha / n:F2}%)"); + + // "Density" for fake-volumetric look: % of pixels that contribute a visible + // streak. Use max(alpha>128, lum>200, nonZeroAlpha) as the proxy — different + // textures encode the streak via either alpha-clip or pure luminance. + double densityFrac = Math.Max(brightAlpha, Math.Max(brightLum, nonZeroAlpha)) / (double)n; + if (best is null || densityFrac > best.Value.densityFraction) + best = (id, densityFrac); + + string pngPath = Path.Combine(outDir, $"tex_{id:X8}.png"); + WritePng(pngPath, dec.Rgba8, dec.Width, dec.Height); + Console.WriteLine($" PNG: {pngPath}"); +} + +Console.WriteLine(); +if (best is not null) + Console.WriteLine($"DENSEST: 0x{best.Value.id:X8} (density frac {best.Value.densityFraction:F4})"); +return 0; + +// -------- Minimal PNG encoder (RGBA8, no filter, single IDAT, zlib via DeflateStream) -------- +static void WritePng(string path, byte[] rgba, int width, int height) +{ + using var fs = File.Create(path); + // Signature + fs.Write(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A }); + + // IHDR + var ihdr = new byte[13]; + WriteBE(ihdr, 0, (uint)width); + WriteBE(ihdr, 4, (uint)height); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + WriteChunk(fs, "IHDR", ihdr); + + // IDAT: rows prefixed with filter byte 0, zlib-wrapped deflate. + using var raw = new MemoryStream(); + for (int y = 0; y < height; y++) + { + raw.WriteByte(0); + raw.Write(rgba, y * width * 4, width * 4); + } + byte[] uncompressed = raw.ToArray(); + + using var compressed = new MemoryStream(); + // zlib header: 0x78 0x9C (deflate, default compression) + compressed.WriteByte(0x78); + compressed.WriteByte(0x9C); + using (var deflate = new DeflateStream(compressed, CompressionLevel.Fastest, leaveOpen: true)) + deflate.Write(uncompressed, 0, uncompressed.Length); + // Adler-32 of uncompressed data, big-endian. + uint adler = Adler32(uncompressed); + compressed.WriteByte((byte)(adler >> 24)); + compressed.WriteByte((byte)(adler >> 16)); + compressed.WriteByte((byte)(adler >> 8)); + compressed.WriteByte((byte)adler); + + WriteChunk(fs, "IDAT", compressed.ToArray()); + WriteChunk(fs, "IEND", Array.Empty()); +} + +static void WriteBE(byte[] buf, int offset, uint v) +{ + buf[offset + 0] = (byte)(v >> 24); + buf[offset + 1] = (byte)(v >> 16); + buf[offset + 2] = (byte)(v >> 8); + buf[offset + 3] = (byte)v; +} + +static void WriteChunk(Stream s, string type, byte[] data) +{ + var len = new byte[4]; + WriteBE(len, 0, (uint)data.Length); + s.Write(len); + var typeBytes = System.Text.Encoding.ASCII.GetBytes(type); + s.Write(typeBytes); + s.Write(data); + var crcInput = new byte[typeBytes.Length + data.Length]; + Buffer.BlockCopy(typeBytes, 0, crcInput, 0, typeBytes.Length); + Buffer.BlockCopy(data, 0, crcInput, typeBytes.Length, data.Length); + uint crc = Crc32(crcInput); + s.WriteByte((byte)(crc >> 24)); + s.WriteByte((byte)(crc >> 16)); + s.WriteByte((byte)(crc >> 8)); + s.WriteByte((byte)crc); +} + +static uint Crc32(byte[] data) +{ + uint c = 0xFFFFFFFFu; + foreach (var b in data) + { + uint v = (c ^ b) & 0xFF; + for (int k = 0; k < 8; k++) + v = ((v & 1) != 0) ? (0xEDB88320u ^ (v >> 1)) : (v >> 1); + c = v ^ (c >> 8); + } + return c ^ 0xFFFFFFFFu; +} + +static uint Adler32(byte[] data) +{ + const uint MOD = 65521; + uint a = 1, b = 0; + foreach (var x in data) + { + a = (a + x) % MOD; + b = (b + a) % MOD; + } + return (b << 16) | a; +} diff --git a/tools/TextureDump/TextureDump.csproj b/tools/TextureDump/TextureDump.csproj new file mode 100644 index 0000000..53622fb --- /dev/null +++ b/tools/TextureDump/TextureDump.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + TextureDump + + + + + + + + diff --git a/tools/WeatherEnumerator/Program.cs b/tools/WeatherEnumerator/Program.cs new file mode 100644 index 0000000..b51ab24 --- /dev/null +++ b/tools/WeatherEnumerator/Program.cs @@ -0,0 +1,139 @@ +// WeatherEnumerator — Issue #26 probe. +// Iterates ALL DayGroups in Dereth (Region 0x13000000), dumps every SkyObject +// flagged with the weather bit (Properties & 0x04), and records the bounding +// box of every unique GfxObj used as a weather visual. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +const uint WEATHER_BIT = 0x04; + +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; +} + +var dayGroups = region.SkyInfo?.DayGroups; +if (dayGroups is null) { Console.Error.WriteLine("No DayGroups"); return 1; } + +Console.WriteLine($"Region loaded. {dayGroups.Count} DayGroups."); +Console.WriteLine(); + +// Collect unique weather GfxObjIds across all DayGroups (incl. SkyTime replaces). +var weatherGfxIds = new HashSet(); +int totalWeatherSkyObjs = 0; + +for (int dg = 0; dg < dayGroups.Count; dg++) +{ + var group = dayGroups[dg]; + string name = group.DayName?.Value ?? "(null)"; + int weatherCount = group.SkyObjects.Count(so => (so.Properties & WEATHER_BIT) != 0); + Console.WriteLine($"=== DayGroup[{dg}] \"{name}\" Chance={group.ChanceOfOccur:F3} SkyObjects={group.SkyObjects.Count} WeatherSkyObjs={weatherCount} ==="); + + for (int oi = 0; oi < group.SkyObjects.Count; oi++) + { + var so = group.SkyObjects[oi]; + if ((so.Properties & WEATHER_BIT) == 0) continue; + totalWeatherSkyObjs++; + uint gfx = (uint)so.DefaultGfxObjectId; + uint pes = (uint)so.DefaultPesObjectId; + Console.WriteLine($" WEATHER OI={oi} Begin={so.BeginTime:F3} End={so.EndTime:F3} BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1}"); + Console.WriteLine($" TexVel=({so.TexVelocityX:F4},{so.TexVelocityY:F4}) Gfx=0x{gfx:X8} Pes=0x{pes:X8} Props=0x{so.Properties:X8}"); + if (gfx != 0) weatherGfxIds.Add(gfx); + } + + // Also scan SkyObjReplace entries for weather slots (replaces never carry + // their own Properties, but the same OI's weather bit means the replace + // is also a weather visual — record the gfx). + var weatherIndices = new HashSet( + Enumerable.Range(0, group.SkyObjects.Count) + .Where(i => (group.SkyObjects[i].Properties & WEATHER_BIT) != 0)); + foreach (var st in group.SkyTime) + { + foreach (var r in st.SkyObjReplace) + { + if (!weatherIndices.Contains((int)r.ObjectIndex)) continue; + uint gfx = (uint)r.GfxObjId; + if (gfx != 0 && weatherGfxIds.Add(gfx)) + Console.WriteLine($" WEATHER REPLACE SkyTime.Begin={st.Begin:F3} OI={r.ObjectIndex} Gfx=0x{gfx:X8}"); + } + } + Console.WriteLine(); +} + +Console.WriteLine($"Total weather SkyObjects across all DayGroups: {totalWeatherSkyObjs}"); +Console.WriteLine($"Unique weather GfxObjIds: {weatherGfxIds.Count}"); +Console.WriteLine(); +Console.WriteLine("=== Bounding boxes of unique weather GfxObjs ==="); +Console.WriteLine("GfxObjId | Verts | minX minY minZ | maxX maxY maxZ | sizeX sizeY sizeZ | radius"); +Console.WriteLine(new string('-', 130)); + +foreach (uint gid in weatherGfxIds.OrderBy(x => x)) +{ + DumpBounds(dats, gid); +} + +return 0; + +static void DumpBounds(DatCollection dats, uint gid) +{ + if (gid >= 0x02000000u) + { + if (!dats.TryGet(gid, out var setup) || setup is null) + { + Console.WriteLine($"0x{gid:X8} | (Setup not found)"); + return; + } + Console.WriteLine($"0x{gid:X8} | Setup with {setup.Parts.Count} part(s):"); + foreach (var p in setup.Parts) DumpGfxBounds(dats, (uint)p, indent: " "); + return; + } + DumpGfxBounds(dats, gid, indent: ""); +} + +static void DumpGfxBounds(DatCollection dats, uint gid, string indent) +{ + if (!dats.TryGet(gid, out var go) || go is null) + { + Console.WriteLine($"{indent}0x{gid:X8} | (GfxObj not found)"); + return; + } + var verts = go.VertexArray?.Vertices; + if (verts is null || verts.Count == 0) + { + Console.WriteLine($"{indent}0x{gid:X8} | 0 verts (no geometry)"); + return; + } + + Vector3 mn = new(float.MaxValue), mx = new(float.MinValue); + foreach (var kv in verts) + { + var v = kv.Value; + var p = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + mn = Vector3.Min(mn, p); + mx = Vector3.Max(mx, p); + } + var size = mx - mn; + float radius = Math.Max(size.X, Math.Max(size.Y, size.Z)) * 0.5f; + Console.WriteLine( + $"{indent}0x{gid:X8} | {verts.Count,5} | " + + $"{mn.X,8:F2} {mn.Y,8:F2} {mn.Z,8:F2} | " + + $"{mx.X,8:F2} {mx.Y,8:F2} {mx.Z,8:F2} | " + + $"{size.X,8:F2} {size.Y,8:F2} {size.Z,8:F2} | {radius,7:F2}"); +} diff --git a/tools/WeatherEnumerator/WeatherEnumerator.csproj b/tools/WeatherEnumerator/WeatherEnumerator.csproj new file mode 100644 index 0000000..5d89c3e --- /dev/null +++ b/tools/WeatherEnumerator/WeatherEnumerator.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + WeatherEnumerator + + + + + + + diff --git a/tools/WeatherSetupProbe/Program.cs b/tools/WeatherSetupProbe/Program.cs new file mode 100644 index 0000000..734bef0 --- /dev/null +++ b/tools/WeatherSetupProbe/Program.cs @@ -0,0 +1,120 @@ +// WeatherSetupProbe — Issue #26: dump weather Setups (0x02000BA6, 0x02000588, 0x02000589) +// to determine if any contain a small near-camera billboard / particle layer. +using System; +using System.IO; +using System.Linq; +using System.Numerics; +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); + +uint[] setupIds = { 0x02000BA6u, 0x02000588u, 0x02000589u }; +foreach (uint sid in setupIds) ProbeSetup(dats, sid); +return 0; + +static void ProbeSetup(DatCollection dats, uint sid) +{ + Console.WriteLine(); + Console.WriteLine($"================ Setup 0x{sid:X8} ================"); + if (!dats.TryGet(sid, out var s) || s is null) + { + Console.WriteLine(" (NOT FOUND)"); + return; + } + Console.WriteLine($" Flags={s.Flags} NumParts={s.NumParts}"); + Console.WriteLine($" Setup Height={s.Height:F3} Radius={s.Radius:F3}"); + Console.WriteLine($" SortingSphere center=({s.SortingSphere.Origin.X:F2},{s.SortingSphere.Origin.Y:F2},{s.SortingSphere.Origin.Z:F2}) r={s.SortingSphere.Radius:F3}"); + Console.WriteLine($" SelectionSphere center=({s.SelectionSphere.Origin.X:F2},{s.SelectionSphere.Origin.Y:F2},{s.SelectionSphere.Origin.Z:F2}) r={s.SelectionSphere.Radius:F3}"); + Console.WriteLine($" HoldingLocations={s.HoldingLocations.Count} ConnectionPoints={s.ConnectionPoints.Count} PlacementFrames={s.PlacementFrames.Count} Lights={s.Lights.Count}"); + Console.WriteLine($" DefaultAnimation=0x{(uint)s.DefaultAnimation:X8} DefaultScript=0x{(uint)s.DefaultScript:X8} DefaultMotionTable=0x{(uint)s.DefaultMotionTable:X8}"); + + // PlacementFrames: typically Placement.Default has the per-part frames. + AnimationFrame? defaultFrame = null; + foreach (var kv in s.PlacementFrames) + { + Console.WriteLine($" Placement[{kv.Key}]: Frames={kv.Value.Frames.Count} Hooks={kv.Value.Hooks.Count}"); + if (defaultFrame is null) defaultFrame = kv.Value; + if (kv.Key == Placement.Default) defaultFrame = kv.Value; + } + + for (int pi = 0; pi < s.Parts.Count; pi++) + { + uint partGid = (uint)s.Parts[pi]; + uint parent = (s.Flags.HasFlag(SetupFlags.HasParent) && pi < s.ParentIndex.Count) ? s.ParentIndex[pi] : 0xFFFFFFFFu; + Vector3 scale = (s.Flags.HasFlag(SetupFlags.HasDefaultScale) && pi < s.DefaultScale.Count) ? s.DefaultScale[pi] : new Vector3(1, 1, 1); + Frame? frame = (defaultFrame is not null && pi < defaultFrame.Frames.Count) ? defaultFrame.Frames[pi] : null; + string frameStr = frame is null ? "(no-frame)" + : $"pos=({frame.Origin.X:F2},{frame.Origin.Y:F2},{frame.Origin.Z:F2}) ori=({frame.Orientation.W:F3},{frame.Orientation.X:F3},{frame.Orientation.Y:F3},{frame.Orientation.Z:F3})"; + string parentStr = parent == 0xFFFFFFFFu ? "ROOT" : parent.ToString(); + Console.WriteLine($" Part[{pi}] GfxObj=0x{partGid:X8} parent={parentStr} scale=({scale.X:F2},{scale.Y:F2},{scale.Z:F2}) {frameStr}"); + DumpGfxObj(dats, partGid, " "); + } +} + +static void DumpGfxObj(DatCollection dats, uint gid, string indent) +{ + if (!dats.TryGet(gid, out var g) || g is null) + { + Console.WriteLine($"{indent}GfxObj 0x{gid:X8} NOT FOUND"); + return; + } + int triCount = g.Polygons.Count; + int physTri = g.PhysicsPolygons.Count; + int vertCount = g.VertexArray?.Vertices?.Count ?? 0; + Vector3 mn = new(float.PositiveInfinity), mx = new(float.NegativeInfinity); + if (g.VertexArray?.Vertices is not null) + { + foreach (var v in g.VertexArray.Vertices.Values) + { + mn = Vector3.Min(mn, v.Origin); + mx = Vector3.Max(mx, v.Origin); + } + } + Vector3 size = (vertCount > 0) ? (mx - mn) : Vector3.Zero; + float radius = (vertCount > 0) ? size.Length() * 0.5f : 0f; + string sizeTag = radius < 5 ? " < 0) + { + Console.WriteLine($"{indent} bbox min=({mn.X:F2},{mn.Y:F2},{mn.Z:F2}) max=({mx.X:F2},{mx.Y:F2},{mx.Z:F2}) size=({size.X:F2},{size.Y:F2},{size.Z:F2}) r~{radius:F2}{sizeTag}"); + Console.WriteLine($"{indent} SortCenter=({g.SortCenter.X:F2},{g.SortCenter.Y:F2},{g.SortCenter.Z:F2})"); + // billboard heuristic: 4 verts and 1-2 polygons + if (vertCount <= 8 && triCount <= 4) + Console.WriteLine($"{indent} >>> BILLBOARD CANDIDATE (verts<=8, tris<=4)"); + } + for (int si = 0; si < g.Surfaces.Count; si++) + { + uint surfId = (uint)g.Surfaces[si]; + if (!dats.TryGet(surfId, out var surf) || surf is null) + { + Console.WriteLine($"{indent} Surf[{si}]=0x{surfId:X8} (not found)"); + continue; + } + string tex = "solid"; + if (surf.Type.HasFlag(SurfaceType.Base1Image) || surf.Type.HasFlag(SurfaceType.Base1ClipMap)) + { + uint stid = (uint)surf.OrigTextureId; + if (stid != 0 && dats.TryGet(stid, out var st) && st is not null && st.Textures.Count > 0) + { + uint rsid = (uint)st.Textures[0]; + if (dats.TryGet(rsid, out var rs) && rs is not null) + tex = $"{rs.Width}x{rs.Height} {rs.Format} STex=0x{stid:X8}"; + else + tex = $"STex=0x{stid:X8} (rs miss)"; + } + else tex = $"STex=0x{stid:X8}"; + } + Console.WriteLine($"{indent} Surf[{si}]=0x{surfId:X8} Type={surf.Type} Translucency={surf.Translucency:F3} Lum={surf.Luminosity:F3} Diffuse={surf.Diffuse:F3} {tex}"); + } +} diff --git a/tools/WeatherSetupProbe/WeatherSetupProbe.csproj b/tools/WeatherSetupProbe/WeatherSetupProbe.csproj new file mode 100644 index 0000000..aed6490 --- /dev/null +++ b/tools/WeatherSetupProbe/WeatherSetupProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + WeatherSetupProbe + + + + + + +