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>
313 lines
13 KiB
C#
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 "";
|
|
}
|