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