tools(probe): add StarsProbe to dump every SkyObject's geometry + UVs

Sibling of WeatherEnumerator/PesChainAudit. Walks every DayGroup in the
Dereth Region (0x13000000), prints each SkyObject (Properties bits,
TexVelocity, BeginTime/EndTime, gfx/pes ids), then dumps the underlying
GfxObj's vertices, UV ranges, and surfaces. The crucial diagnostic is
the per-GfxObj "UV range outside [0,1]" flag.

Built for Bug B (sky-investigation-handoff §"Bug B"): stars rendering as
a square in one corner of the sky. Smoking gun on first run: GfxObj
0x010015EF (OI-1 in every DayGroup, TexVelocity = 0) has UVs in
[0.398, 4.602] — meaning the texture tiles ~4× across each face, but
SkyRenderer's "CLAMP_TO_EDGE unless TexVelocity != 0" heuristic forces
clamp on it, so the whole inner dome samples edge texels except the
tiny region where UVs happen to fall in [0,1]. That tiny region is the
"square in one corner" the user observed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 22:19:28 +02:00
parent ff504e9ec1
commit 991fb9a222
2 changed files with 168 additions and 0 deletions

153
tools/StarsProbe/Program.cs Normal file
View file

@ -0,0 +1,153 @@
// StarsProbe — Bug B (sky-investigation-handoff §"Bug B"): dump every
// SkyObject's geometry + UVs to identify the star object and verify
// whether its UV range matches what GL_CLAMP_TO_EDGE supports.
//
// Sibling of WeatherEnumerator/SetupProbe/etc under tools/. Walks all
// DayGroups in the Dereth Region (0x13000000), prints every SkyObject
// (Properties bits, TexVelocity, BeginTime/EndTime), then dumps the
// underlying GfxObj's vertices, UV ranges, and surfaces. The crucial
// diagnostic is the per-GfxObj "UV range outside [0,1]" flag — when
// that's set on a static (non-scrolling) sky object, our SkyRenderer's
// CLAMP_TO_EDGE heuristic mis-samples and the texture appears as a
// "square in one corner" of the geometry.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SysEnv = System.Environment;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
Console.WriteLine($"datDir = {datDir}");
using var dats = new DatCollection(datDir, DatAccessType.Read);
if (!dats.TryGet<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();
var seenGfx = new HashSet<uint>();
for (int dg = 0; dg < dayGroups.Count; dg++)
{
var group = dayGroups[dg];
string name = group.DayName?.Value ?? "(null)";
Console.WriteLine($"=== DayGroup[{dg}] \"{name}\" Chance={group.ChanceOfOccur:F3} SkyObjects={group.SkyObjects.Count} ===");
for (int oi = 0; oi < group.SkyObjects.Count; oi++)
{
var so = group.SkyObjects[oi];
uint gfx = (uint)so.DefaultGfxObjectId;
uint pes = (uint)so.DefaultPesObjectId;
bool wrapsMidnight = so.BeginTime > so.EndTime;
Console.WriteLine(
$" OI={oi,2} Begin={so.BeginTime:F3} End={so.EndTime:F3} {(wrapsMidnight ? "(wraps midnight night candidate)" : "")}");
Console.WriteLine(
$" BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1} TexVel=({so.TexVelocityX:F4},{so.TexVelocityY:F4})");
Console.WriteLine(
$" Gfx=0x{gfx:X8} Pes=0x{pes:X8} Props=0x{so.Properties:X8} (bin={Convert.ToString(so.Properties, 2).PadLeft(8, '0')})");
if (gfx != 0) seenGfx.Add(gfx);
}
// SkyTime replaces (some sky objects swap GfxObj at specific times).
foreach (var st in group.SkyTime)
foreach (var r in st.SkyObjReplace)
{
uint gfx = (uint)r.GfxObjId;
if (gfx != 0 && seenGfx.Add(gfx))
Console.WriteLine($" REPLACE SkyTime.Begin={st.Begin:F3} OI={r.ObjectIndex} Gfx=0x{gfx:X8}");
}
Console.WriteLine();
}
Console.WriteLine($"Unique GfxObjIds across all DayGroups: {seenGfx.Count}");
Console.WriteLine();
Console.WriteLine("=== Per-GfxObj geometry + UV summary ===");
foreach (uint gid in seenGfx.OrderBy(x => x))
DumpGeoAndUVs(dats, gid);
return 0;
static void DumpGeoAndUVs(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) DumpGfx(dats, (uint)p, indent: " ");
return;
}
DumpGfx(dats, gid, indent: "");
}
static void DumpGfx(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"); return; }
Vector3 mn = new(float.MaxValue), mx = new(float.MinValue);
float uMin = float.MaxValue, uMax = float.MinValue;
float vMin = float.MaxValue, vMax = float.MinValue;
int uvLayerMax = 0;
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);
if (v.UVs is { Count: > 0 } uvs)
{
uvLayerMax = Math.Max(uvLayerMax, uvs.Count);
foreach (var uv in uvs)
{
uMin = Math.Min(uMin, uv.U); uMax = Math.Max(uMax, uv.U);
vMin = Math.Min(vMin, uv.V); vMax = Math.Max(vMax, uv.V);
}
}
}
var size = mx - mn;
int polyCount = go.Polygons?.Count ?? 0;
int surfCount = go.Surfaces?.Count ?? 0;
bool uvOutsideUnit = uvLayerMax > 0
&& (uMin < 0f || uMax > 1f || vMin < 0f || vMax > 1f);
Console.WriteLine($"{indent}0x{gid:X8} | verts={verts.Count} polys={polyCount} surfaces={surfCount} uvLayers={uvLayerMax}");
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})");
if (uvLayerMax > 0)
Console.WriteLine($"{indent} UV range U=[{uMin:F3}, {uMax:F3}] V=[{vMin:F3}, {vMax:F3}] {(uvOutsideUnit ? "*** OUTSIDE [0,1] needs REPEAT wrap ***" : "in [0,1]")}");
else
Console.WriteLine($"{indent} UV range (no UVs on any vertex)");
if (go.Surfaces is { Count: > 0 })
for (int i = 0; i < go.Surfaces.Count; i++)
Console.WriteLine($"{indent} Surface[{i}]=0x{(uint)go.Surfaces[i]:X8}");
// Verbose per-vertex dump (capped at 64 verts to keep output bounded).
int dumpN = Math.Min(verts.Count, 64);
int shown = 0;
foreach (var kv in verts)
{
if (shown++ >= dumpN) { Console.WriteLine($"{indent} ...({verts.Count - dumpN} more verts)"); break; }
var v = kv.Value;
string uvStr = v.UVs is null || v.UVs.Count == 0 ? "(none)" : string.Join(" ", v.UVs.Select(u => $"({u.U:F3},{u.V:F3})"));
Console.WriteLine($"{indent} v[{kv.Key,3}] pos=({v.Origin.X,7:F2},{v.Origin.Y,7:F2},{v.Origin.Z,7:F2}) uv={uvStr}");
}
}

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>StarsProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="C:\Users\erikn\source\repos\acdream\references\DatReaderWriter\DatReaderWriter\DatReaderWriter.csproj" />
</ItemGroup>
</Project>