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:
Erik 2026-04-26 21:40:34 +02:00
parent a060f4fc98
commit 8db7a9ec28
11 changed files with 1085 additions and 0 deletions

View 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>

View 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;

View 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;
}

View 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>

View 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}");
}

View 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>

View 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}");
}
}

View 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>