docs(research): sky/weather investigation handoff + diagnostic tools
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) <noreply@anthropic.com>
This commit is contained in:
parent
a060f4fc98
commit
8db7a9ec28
11 changed files with 1085 additions and 0 deletions
82
docs/research/2026-04-26-chorizite-pr-draft.md
Normal file
82
docs/research/2026-04-26-chorizite-pr-draft.md
Normal file
|
|
@ -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<T>(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<ParticleEmitterInfo>(id)` /
|
||||||
|
`Get<ParticleEmitter>(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
|
||||||
158
docs/research/2026-04-26-datreaderwriter-reference.md
Normal file
158
docs/research/2026-04-26-datreaderwriter-reference.md
Normal file
|
|
@ -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<T>(id)` / `Portal.Get<T>(id)` — generic typed read.
|
||||||
|
- `DatCollection.GetAllIdsOfType<T>()` — 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<ParticleEmitter>(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<T>` 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<T>()` 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.
|
||||||
164
docs/research/2026-04-26-sky-investigation-handoff.md
Normal file
164
docs/research/2026-04-26-sky-investigation-handoff.md
Normal file
|
|
@ -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/<name>/` 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=<night-group-index>`), 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.
|
||||||
|
```
|
||||||
15
tools/PesChainAudit/PesChainAudit.csproj
Normal file
15
tools/PesChainAudit/PesChainAudit.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>PesChainAudit</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="C:\Users\erikn\source\repos\acdream\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
133
tools/PesChainAudit/Program.cs
Normal file
133
tools/PesChainAudit/Program.cs
Normal file
|
|
@ -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<uint>();
|
||||||
|
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<DatPhysicsScript>(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<ParticleEmitter>(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;
|
||||||
228
tools/TextureDump/Program.cs
Normal file
228
tools/TextureDump/Program.cs
Normal file
|
|
@ -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<uint>();
|
||||||
|
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<SurfaceTexture>(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<RenderSurface>(rsid, out var inner) && inner is not null)
|
||||||
|
rs = inner;
|
||||||
|
}
|
||||||
|
else if (dats.TryGet<RenderSurface>(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<Palette>(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<byte>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
16
tools/TextureDump/TextureDump.csproj
Normal file
16
tools/TextureDump/TextureDump.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>TextureDump</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
139
tools/WeatherEnumerator/Program.cs
Normal file
139
tools/WeatherEnumerator/Program.cs
Normal file
|
|
@ -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<Region>(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<uint>();
|
||||||
|
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<int>(
|
||||||
|
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<Setup>(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<GfxObj>(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}");
|
||||||
|
}
|
||||||
15
tools/WeatherEnumerator/WeatherEnumerator.csproj
Normal file
15
tools/WeatherEnumerator/WeatherEnumerator.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>WeatherEnumerator</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="C:\Users\erikn\source\repos\acdream\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
120
tools/WeatherSetupProbe/Program.cs
Normal file
120
tools/WeatherSetupProbe/Program.cs
Normal file
|
|
@ -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<Setup>(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<GfxObj>(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 ? " <<TINY"
|
||||||
|
: radius < 20 ? " <<SMALL"
|
||||||
|
: radius < 200 ? "" : " <<HUGE";
|
||||||
|
Console.WriteLine($"{indent}GfxObj 0x{gid:X8} Flags={g.Flags} Surfaces={g.Surfaces.Count} RenderTris={triCount} PhysTris={physTri} Verts={vertCount}");
|
||||||
|
if (vertCount > 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<Surface>(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<SurfaceTexture>(stid, out var st) && st is not null && st.Textures.Count > 0)
|
||||||
|
{
|
||||||
|
uint rsid = (uint)st.Textures[0];
|
||||||
|
if (dats.TryGet<RenderSurface>(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tools/WeatherSetupProbe/WeatherSetupProbe.csproj
Normal file
15
tools/WeatherSetupProbe/WeatherSetupProbe.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>WeatherSetupProbe</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue