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>
This commit is contained in:
Erik 2026-05-05 14:45:50 +02:00
parent a3f53c2644
commit 5937ebe1c5
3 changed files with 368 additions and 16 deletions

View file

@ -310,22 +310,35 @@ What we confirmed (data is correct):
matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's
`Frame::combine` at `0x00518FD0`. `Frame::combine` at `0x00518FD0`.
**Remaining hypothesis space (untested):** **Investigation 2 (2026-05-04, 5 parallel agents + dat probes):**
1. **Texture decode produces skin pixels** for `0x05001AFE/0x05001AFC` ALL of the obvious hypotheses ruled out:
where ACME / retail produces coat pixels. Compare our SurfaceDecoder
against ACME's `TextureHelpers.cs` for INDEX16 / palette-indexed - **Byte-level decode primitive matches ACViewer.** INDEX16/P8/DXT/BGRA paths are byte-identical.
chains. - **Polygon emission matches retail.** All 43 polygons of gfx `0x0100120D` are `SidesType=0` (ST_SINGLE), all surfaces are `Base1Image` — NO ST_DOUBLE polygons we'd be missing, NO surfaces lacking the `Type & 6` bits that retail's `DrawPolyInternal` skips.
2. **Polygon-to-surface mapping off-by-one.** Specific polygons of - **Per-PART texture-override scoping is correct.** `resolvedOverridesByPart[partIdx]` gets per-MeshRef'd; not a global flat map (Agent 3's claim was wrong).
part 9 reference an unintended surface. Add a dump: for each polygon - **SubPalettes are full-size (Colors.Count=2048) palettes.** Our `subPal.Colors[idx]` indexing matches ACViewer's `newPalette.Colors[j + offset]`.
in gfx 0x0100120D, print `PosSurface` index + the resolved Surface id. - **The `*8` wire un-pack is correctly single-applied** (parser stores raw bytes; ComposePalette multiplies once).
3. **Multi-layer texture composition retail does and we skip.** AC's
"ApplyCloth" or similar layered texture step. Grep **The actual smoking gun (Investigation 2):**
`acclient_2013_pseudo_c.txt` for `BlendBaseLayer`, `LayerSurfaces`,
any composition method that combines multiple textures into one. For `+Acdream` the server sends 10 SubPaletteSwap ranges that overlay palette indices:
4. **UV mapping bug.** Part 9's polygon UVs map to a skin region of `[0..320)`, `[576..1024)`, `[1392..1488)`, `[1728..1920)`. **The complement — indices `[320..576)`, `[1024..1392)`, `[1488..1728)`, `[1920..2048)` — is NOT overlaid.** Base palette `0x0400007E` at those indices contains the original red/skin tones (sampled values: `0x46 0x22 0x04`, `0x4A 0x28 0x09`, etc).
the coat texture. Dump per-vertex UV vs vertex Z; if a high-Z vertex
has UV.v near a skin region, that's the source. If the coat texture's UVs at the upper region map to texel-bytes whose palette index lands in one of those non-overlaid ranges, those pixels render with base-palette skin tones. That's the visible "skin stub at the top of the coat".
**Working hypothesis:** either
1. ACE sends incomplete SubPalette ranges (retail-original would cover the full palette)
2. Retail does *additional* client-side compute that ACE pre-resolves wrongly
3. The base palette `0x0400007E` itself is supposed to have coat colors at those indices in retail's interpretation (different palette decode)
**Next investigation (deferred):**
- Diff ACE's `WorldObject_Networking.cs` CharGen ObjDesc construction against retail's
`ClothingTable::BuildObjDesc` (`acclient_2013_pseudo_c.txt:436261`). Check if ACE
actually walks every CloSubPaletteRange in the chosen PaletteTemplate, or skips some.
- RenderDoc capture: confirm which texel/palette-index the upper-region polygons sample.
- `tools/InspectCoatTex/Program.cs` is the diagnostic harness — extend it.
**Files (diagnostic env vars committed for next-session reuse):** **Files (diagnostic env vars committed for next-session reuse):**

View file

@ -2008,7 +2008,33 @@ public sealed class GameWindow : IDisposable
if (spawn.SubPalettes is { } subPaletteList) if (spawn.SubPalettes is { } subPaletteList)
{ {
foreach (var subPal in subPaletteList) foreach (var subPal in subPaletteList)
Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); {
int rawOffset = subPal.Offset * 8;
int rawLen = subPal.Length == 0 ? 2048 : subPal.Length * 8;
var pal = _dats.Get<DatReaderWriter.DBObjs.Palette>(subPal.SubPaletteId);
string palInfo = pal is null ? "Palette dat NOT FOUND (might be PaletteSet 0x0F?)" : $"Colors.Count={pal.Colors.Count}";
Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} wireOffset={subPal.Offset} wireLength={subPal.Length} -> rawIdx[{rawOffset}..{rawOffset + rawLen}) {palInfo}");
// If pal is non-null and small, show first 4 colors
if (pal is not null && pal.Colors.Count > 0)
{
int sample = Math.Min(4, pal.Colors.Count);
for (int s = 0; s < sample; s++)
{
var c = pal.Colors[s];
Console.WriteLine($" pal[{s:D3}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}");
}
// Also probe at the rawOffset (if in range) — that's where overlay copies FROM in our code
if (rawOffset < pal.Colors.Count)
{
var c = pal.Colors[rawOffset];
Console.WriteLine($" pal[{rawOffset:D4}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2} <-- our code reads here");
}
else
{
Console.WriteLine($" pal[{rawOffset:D4}] OUT OF RANGE (Colors.Count={pal.Colors.Count}) -- our code's read SKIPS the overlay !!");
}
}
}
} }
} }
foreach (var change in animPartChanges) foreach (var change in animPartChanges)

View file

@ -0,0 +1,313 @@
// 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 "";
}