From 5937ebe1c5d21b490d23ec20bcc365c8a867bd35 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 14:45:50 +0200 Subject: [PATCH] =?UTF-8?q?docs(issues):=20#37=20=E2=80=94=20Investigation?= =?UTF-8?q?=202=20narrows=20bug=20to=20SubPalette=20coverage=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/ISSUES.md | 43 ++-- src/AcDream.App/Rendering/GameWindow.cs | 28 ++- tools/InspectCoatTex/Program.cs | 313 ++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 16 deletions(-) create mode 100644 tools/InspectCoatTex/Program.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 794a37b..9ecbc0e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -310,22 +310,35 @@ What we confirmed (data is correct): matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's `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` - where ACME / retail produces coat pixels. Compare our SurfaceDecoder - against ACME's `TextureHelpers.cs` for INDEX16 / palette-indexed - chains. -2. **Polygon-to-surface mapping off-by-one.** Specific polygons of - part 9 reference an unintended surface. Add a dump: for each polygon - in gfx 0x0100120D, print `PosSurface` index + the resolved Surface id. -3. **Multi-layer texture composition retail does and we skip.** AC's - "ApplyCloth" or similar layered texture step. Grep - `acclient_2013_pseudo_c.txt` for `BlendBaseLayer`, `LayerSurfaces`, - any composition method that combines multiple textures into one. -4. **UV mapping bug.** Part 9's polygon UVs map to a skin region of - 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. +ALL of the obvious hypotheses ruled out: + +- **Byte-level decode primitive matches ACViewer.** INDEX16/P8/DXT/BGRA paths are byte-identical. +- **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. +- **Per-PART texture-override scoping is correct.** `resolvedOverridesByPart[partIdx]` gets per-MeshRef'd; not a global flat map (Agent 3's claim was wrong). +- **SubPalettes are full-size (Colors.Count=2048) palettes.** Our `subPal.Colors[idx]` indexing matches ACViewer's `newPalette.Colors[j + offset]`. +- **The `*8` wire un-pack is correctly single-applied** (parser stores raw bytes; ComposePalette multiplies once). + +**The actual smoking gun (Investigation 2):** + +For `+Acdream` the server sends 10 SubPaletteSwap ranges that overlay palette indices: +`[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). + +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):** diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fdb71a9..cf62d77 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2008,7 +2008,33 @@ public sealed class GameWindow : IDisposable if (spawn.SubPalettes is { } 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(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) diff --git a/tools/InspectCoatTex/Program.cs b/tools/InspectCoatTex/Program.cs new file mode 100644 index 0000000..58fdaab --- /dev/null +++ b/tools/InspectCoatTex/Program.cs @@ -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(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(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(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(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(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(origTex, out var st) && st is not null && st.Textures.Count > 0) + { + uint rsid = (uint)st.Textures[0]; + if (dats.TryGet(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(gfxId, out var gfx) || gfx is null) return; + foreach (var sQid in gfx.Surfaces) + { + uint sid = (uint)sQid; + if (!dats.TryGet(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(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(); + 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(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(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(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(); + 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(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 ""; +}