acdream/tools/InspectCoatTex/Program.cs
Erik 5937ebe1c5 docs(issues): #37 — Investigation 2 narrows bug to SubPalette coverage gaps
Five parallel agents + dat probes ruled out:
- byte-level decode primitive (matches ACViewer)
- polygon emission (no ST_DOUBLE / Surface.Type & 6 issues)
- per-PART texture-override scoping (correctly per-MeshRef'd)
- SubPalette indexing convention (full-size 2048 palettes, *8 wire un-pack
  is single-applied)

Smoking gun: for +Acdream the server sends 10 SubPaletteSwap ranges that
overlay palette indices [0..320), [576..1024), [1392..1488), [1728..1920).
The complement — [320..576), [1024..1392), [1488..1728), [1920..2048) —
is NOT overlaid. Base palette 0x0400007E at those indices has
red/skin tones. Coat texture UVs sampling those non-overlaid indices
render as visible "skin stub at top of coat".

Either ACE sends incomplete SubPaletteSwap data, or retail does extra
client-side ClothingTable computation we (and ACE) don't.

Diagnostic harness now lives at tools/InspectCoatTex/Program.cs;
GameWindow's DUMP_CLOTHING also probes runtime SubPalette dat sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:45:50 +02:00

313 lines
13 KiB
C#

// InspectCoatTex — Issue #37 diagnostic.
// Inspect the byte-level contents of the SurfaceTextures the server sends in
// TextureChanges for the player's Academy Coat (part 9 / Aluvian Male):
// new: 0x05001AFE, 0x05001AFC
// old: 0x050003D5, 0x050003D4
// And dump every Surface (0x08) under the part 9 GfxObj 0x0100120D, plus the
// DefaultPaletteId of each new texture (and the first 32 entries of that
// palette to see if it's a SKIN or COAT palette).
using System;
using System.IO;
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);
uint[] surfaceTextureIds = { 0x05001AFEu, 0x05001AFCu, 0x050003D5u, 0x050003D4u };
uint part9GfxObj = 0x0100120Du;
Console.WriteLine();
Console.WriteLine("==================== SurfaceTexture (0x05) wrappers ====================");
foreach (var stid in surfaceTextureIds)
DumpSurfaceTexture(dats, stid);
Console.WriteLine();
Console.WriteLine("==================== Part 9 GfxObj 0x0100120D Surfaces (0x08) ====================");
DumpGfxObjSurfaces(dats, part9GfxObj);
Console.WriteLine();
Console.WriteLine("==================== Cross-check: which Surfaces reference the OLD textures? ====================");
DumpReverseLookup(dats, part9GfxObj, new[] { 0x050003D5u, 0x050003D4u });
Console.WriteLine();
Console.WriteLine("==================== Per-polygon flags for gfx 0x0100120D (testing Agent 4 hypotheses) ====================");
DumpPolygons(dats, part9GfxObj);
Console.WriteLine();
Console.WriteLine("==================== Academy Coat ClothingBase + its SubPalettes (testing compact-vs-full-palette hypothesis) ====================");
DumpClothingBase(dats, 0x10000ABFu);
return 0;
// -----------------------------------------------------------------------------
static void DumpSurfaceTexture(DatCollection dats, uint stid)
{
Console.WriteLine();
Console.WriteLine($"--- SurfaceTexture 0x{stid:X8} ---");
if (!dats.TryGet<SurfaceTexture>(stid, out var st) || st is null)
{
Console.WriteLine(" NOT FOUND as SurfaceTexture.");
return;
}
Console.WriteLine($" Textures.Count = {st.Textures.Count}");
if (st.Textures.Count == 0) return;
uint rsid = (uint)st.Textures[0];
Console.WriteLine($" First inner Texture id = 0x{rsid:X8}");
if (!dats.TryGet<RenderSurface>(rsid, out var rs) || rs is null)
{
Console.WriteLine(" Inner not found as RenderSurface.");
return;
}
Console.WriteLine($" Format = {rs.Format} (0x{(uint)rs.Format:X8})");
Console.WriteLine($" Dimensions = {rs.Width} x {rs.Height}");
Console.WriteLine($" SourceData bytes = {rs.SourceData?.Length ?? 0}");
Console.WriteLine($" DefaultPaletteId = 0x{rs.DefaultPaletteId:X8}");
Palette? palette = null;
if (rs.DefaultPaletteId != 0
&& dats.TryGet<Palette>(rs.DefaultPaletteId, out var pal)
&& pal is not null)
{
palette = pal;
Console.WriteLine($" Palette.Colors.Count = {pal.Colors.Count}");
Console.WriteLine($" Palette[0..31]:");
int show = Math.Min(32, pal.Colors.Count);
for (int i = 0; i < show; i++)
{
var c = pal.Colors[i];
byte a = c.Alpha, r = c.Red, g = c.Green, b = c.Blue;
string desc = ClassifyColor(r, g, b);
Console.WriteLine($" [{i:D2}] A={a:X2} R={r:X2} G={g:X2} B={b:X2} {desc}");
}
}
else if (rs.DefaultPaletteId != 0)
{
Console.WriteLine(" DefaultPalette not found in dats.");
}
var dec = SurfaceDecoder.DecodeRenderSurface(rs, palette);
if (dec.Rgba8 is null || dec.Rgba8.Length == 0)
{
Console.WriteLine(" Decode FAILED.");
return;
}
long sumR = 0, sumG = 0, sumB = 0, sumA = 0;
int n = dec.Width * dec.Height;
for (int i = 0; i < n; i++)
{
sumR += dec.Rgba8[i * 4 + 0];
sumG += dec.Rgba8[i * 4 + 1];
sumB += dec.Rgba8[i * 4 + 2];
sumA += dec.Rgba8[i * 4 + 3];
}
byte mr = (byte)(sumR / n), mg = (byte)(sumG / n), mb = (byte)(sumB / n), ma = (byte)(sumA / n);
Console.WriteLine($" Decoded mean RGBA = R={mr:X2} G={mg:X2} B={mb:X2} A={ma:X2} {ClassifyColor(mr, mg, mb)}");
}
static void DumpGfxObjSurfaces(DatCollection dats, uint gfxId)
{
Console.WriteLine();
Console.WriteLine($"--- GfxObj 0x{gfxId:X8} ---");
if (!dats.TryGet<GfxObj>(gfxId, out var gfx) || gfx is null)
{
Console.WriteLine(" NOT FOUND.");
return;
}
Console.WriteLine($" Surfaces.Count = {gfx.Surfaces.Count}");
foreach (var sQid in gfx.Surfaces)
{
uint sid = (uint)sQid;
if (!dats.TryGet<Surface>(sid, out var surf) || surf is null)
{
Console.WriteLine($" Surface 0x{sid:X8}: NOT FOUND");
continue;
}
Console.WriteLine($" Surface 0x{sid:X8}");
Console.WriteLine($" Type = {surf.Type}");
Console.WriteLine($" OrigTextureId = 0x{surf.OrigTextureId:X8}");
Console.WriteLine($" OrigPaletteId = 0x{surf.OrigPaletteId:X8}");
Console.WriteLine($" ColorValue = 0x{surf.ColorValue:X8}");
Console.WriteLine($" Translucency = {surf.Translucency}");
Console.WriteLine($" Luminosity = {surf.Luminosity}");
Console.WriteLine($" Diffuse = {surf.Diffuse}");
// What does that OrigTextureId actually wrap?
uint origTex = (uint)surf.OrigTextureId;
if (origTex != 0 && dats.TryGet<SurfaceTexture>(origTex, 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)
{
Console.WriteLine($" -> wraps RS 0x{rsid:X8}: {rs.Format} {rs.Width}x{rs.Height} defaultPal=0x{rs.DefaultPaletteId:X8}");
}
}
}
}
static void DumpReverseLookup(DatCollection dats, uint gfxId, uint[] oldTextures)
{
if (!dats.TryGet<GfxObj>(gfxId, out var gfx) || gfx is null) return;
foreach (var sQid in gfx.Surfaces)
{
uint sid = (uint)sQid;
if (!dats.TryGet<Surface>(sid, out var surf) || surf is null) continue;
uint origTex = (uint)surf.OrigTextureId;
foreach (var oldT in oldTextures)
{
if (origTex == oldT)
Console.WriteLine($" Surface 0x{sid:X8} OrigTextureId=0x{origTex:X8} matches OLD 0x{oldT:X8} (server replaces this)");
}
}
}
static void DumpClothingBase(DatCollection dats, uint cbId)
{
Console.WriteLine();
Console.WriteLine($"--- ClothingTable 0x{cbId:X8} ---");
if (!dats.TryGet<ClothingTable>(cbId, out var cb) || cb is null)
{
Console.WriteLine(" NOT FOUND.");
return;
}
Console.WriteLine($" ClothingBaseEffects.Count = {cb.ClothingBaseEffects.Count}");
Console.WriteLine($" ClothingSubPalEffects.Count = {cb.ClothingSubPalEffects.Count}");
// For each SubPalEffect (PaletteTemplate variant), dump its CloSubPalettes
var palettesProbed = new System.Collections.Generic.HashSet<uint>();
int subPalEffectIdx = 0;
foreach (var (paletteTemplate, subPalEffect) in cb.ClothingSubPalEffects)
{
if (subPalEffectIdx++ >= 3) break; // first 3 only
Console.WriteLine();
Console.WriteLine($" ClothingSubPalEffect[paletteTemplate=0x{(uint)paletteTemplate:X8}]:");
Console.WriteLine($" Icon = 0x{(uint)subPalEffect.Icon:X8}");
Console.WriteLine($" CloSubPalettes.Count = {subPalEffect.CloSubPalettes.Count}");
foreach (var sp in subPalEffect.CloSubPalettes)
{
uint subPalId = (uint)sp.PaletteSet;
Console.WriteLine($" CloSubPalette PaletteSet=0x{subPalId:X8}, Ranges.Count={sp.Ranges.Count}");
foreach (var r in sp.Ranges)
{
Console.WriteLine($" Range Offset={r.Offset} NumColors={r.NumColors} (raw palette indices)");
}
// Probe whatever this id resolves to. AC has both Palette (0x04) and
// PaletteSet (0x0F) types; without strong typing we just probe both.
if (palettesProbed.Add(subPalId))
{
if (dats.TryGet<Palette>(subPalId, out var palDirect) && palDirect is not null)
{
Console.WriteLine($" -> Palette 0x{subPalId:X8}: Colors.Count={palDirect.Colors.Count}");
if (palDirect.Colors.Count > 0)
{
var c = palDirect.Colors[0];
Console.WriteLine($" [00] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}");
}
if (palDirect.Colors.Count > 24)
{
var c = palDirect.Colors[24];
Console.WriteLine($" [24] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}");
}
if (palDirect.Colors.Count > 100)
{
var c = palDirect.Colors[100];
Console.WriteLine($" [100] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}");
}
}
else
{
Console.WriteLine($" -> NOT a direct Palette (might be PaletteSet 0x0F — AC has dat type for set-of-palettes)");
}
}
}
}
}
static void DumpPolygons(DatCollection dats, uint gfxId)
{
Console.WriteLine();
Console.WriteLine($"--- Polygons of GfxObj 0x{gfxId:X8} ---");
if (!dats.TryGet<GfxObj>(gfxId, out var gfx) || gfx is null)
{
Console.WriteLine(" NOT FOUND.");
return;
}
Console.WriteLine($" Polygon count = {gfx.Polygons.Count}");
Console.WriteLine($" Surfaces[0..N] (for cross-ref):");
for (int i = 0; i < gfx.Surfaces.Count; i++)
{
uint sid = (uint)gfx.Surfaces[i];
if (dats.TryGet<Surface>(sid, out var surf) && surf is not null)
Console.WriteLine($" [{i}] surfId=0x{sid:X8} Type={surf.Type}");
}
Console.WriteLine();
Console.WriteLine($" {"PolyKey",6} {"SidesType",-12} {"Stippling",-32} {"NumVerts",8} {"PosSurf",7} {"NegSurf",7} {"PosUVs",6} {"NegUVs",6}");
int idx = 0;
int stDouble = 0, stBoth = 0, stSingle = 0;
int nonImageSurf = 0;
var sidesTypeHistogram = new System.Collections.Generic.Dictionary<int, int>();
foreach (var (key, p) in gfx.Polygons)
{
// Count first
int sti = (int)p.SidesType;
if (!sidesTypeHistogram.ContainsKey(sti)) sidesTypeHistogram[sti] = 0;
sidesTypeHistogram[sti]++;
if (sti == 0) stSingle++;
else if (sti == 1) stDouble++;
else if (sti == 2) stBoth++;
// Check Type & 6 gate
if (p.PosSurface >= 0 && p.PosSurface < gfx.Surfaces.Count)
{
uint sid = (uint)gfx.Surfaces[p.PosSurface];
if (dats.TryGet<Surface>(sid, out var surf) && surf is not null)
{
uint typeBits = (uint)surf.Type;
bool hasImage = (typeBits & 0x02) != 0;
bool hasClipMap = (typeBits & 0x04) != 0;
if (!hasImage && !hasClipMap) nonImageSurf++;
}
}
if (idx < 30 || sti != 0) // first 30 + all unusual SidesType
{
Console.WriteLine($" {key,6} {p.SidesType,-12} {p.Stippling,-32} {p.VertexIds.Count,8} {p.PosSurface,7} {p.NegSurface,7} {p.PosUVIndices.Count,6} {p.NegUVIndices.Count,6}");
}
idx++;
}
Console.WriteLine();
Console.WriteLine($" SidesType histogram: {string.Join(", ", sidesTypeHistogram.Select(kv => $"{kv.Key}={kv.Value}"))}");
Console.WriteLine($" SidesType=0 (ST_SINGLE) = {stSingle}");
Console.WriteLine($" SidesType=1 (ST_DOUBLE / None) = {stDouble} <-- if >0, retail draws BOTH faces; acdream draws ONE");
Console.WriteLine($" SidesType=2 (ST_BOTH / Clockwise) = {stBoth}");
Console.WriteLine($" Polygons with surface lacking Base1Image+Base1ClipMap: {nonImageSurf}");
Console.WriteLine($" (retail SKIPS these polygons in DrawPolyInternal; acdream renders them with surface.ColorValue)");
}
// Heuristic name for what RGB looks like: skin tones (peach/tan), coat tones
// (blue/purple/red), neutral grey. Cheap classifier so we can eyeball palette
// dumps without staring at raw hex.
static string ClassifyColor(byte r, byte g, byte b)
{
int max = Math.Max(r, Math.Max(g, b));
int min = Math.Min(r, Math.Min(g, b));
int range = max - min;
if (max < 16) return "(near-black)";
if (range < 12) return "(grey)";
// Skin-tone heuristic: R > G > B, R-G in ~5..40, G-B in ~5..40, all >= 60.
if (r > g && g > b && (r - g) >= 4 && (g - b) >= 2 && r >= 60 && g >= 40)
return "(skin-tone-ish)";
if (b > r && b > g) return "(blue-ish)";
if (r > g && r > b) return "(red-ish)";
if (g > r && g > b) return "(green-ish)";
return "";
}