acdream/tools/TextureDump/Program.cs
Erik 1405dd8e90 feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:14:43 +02:00

254 lines
8.9 KiB
C#

// 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}");
uint[] ids;
if (args.Length > 0)
{
ids = args.Select(ParseId).ToArray();
}
else
{
// 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);
ids = idList.ToArray();
}
(uint id, double densityFraction)? best = null;
foreach (var id in ids)
{
Console.WriteLine();
Console.WriteLine($"=== 0x{id:X8} ===");
RenderSurface? rs = null;
uint lookupId = id;
if (dats.TryGet<Surface>(id, out var surface) && surface is not null)
{
lookupId = (uint)surface.OrigTextureId;
var color = surface.ColorValue is null
? "null"
: $"0x{surface.ColorValue.Alpha:X2}{surface.ColorValue.Red:X2}{surface.ColorValue.Green:X2}{surface.ColorValue.Blue:X2}";
Console.WriteLine($" Surface descriptor, type={surface.Type}, color={color}, origTexture=0x{lookupId:X8}");
}
// SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx).
if (dats.TryGet<SurfaceTexture>(lookupId, 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>(lookupId, 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;
}
static uint ParseId(string text)
{
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return Convert.ToUInt32(text[2..], 16);
return Convert.ToUInt32(text, 16);
}