docs(issues): #37 humanoid coat doesn't extend up to neck (env-var diagnostics committed)

Filed as #37 after a ~3 hr investigation that ruled out animation source,
backface culling/winding, palette overlay, and head-GfxObj polygons.

Confirmed:
- Stub is from part 9 (upper torso/coat) post-AnimPartChange (gfx 0x0100120D)
- Part 9's both surfaces ARE matched by our 2 TextureChanges
- Server data complete; composition formula matches ACME + retail decomp

Untested hypothesis space (next session):
- Texture decode chain (compare our SurfaceDecoder vs ACME TextureHelpers)
- Polygon-to-surface index off-by-one on part 9
- Multi-layer texture composition AC may do
- UV mapping bug

Diagnostic env vars committed to source for next-session reuse:
- ACDREAM_HIDE_PART=N — hide specific humanoid part to localize bugs
- ACDREAM_NO_CULL=1 — disable backface culling
- ACDREAM_DUMP_CLOTHING=1 — dump APC + TC + per-part Surface chain coverage

Bug was originally reported as "head/neck protrudes forward"; the apparent
forward shift turned out to be an optical illusion from the missing
coat collar. Math + cdb-ground-truth + ACME comparison confirmed the
head placement is correct retail-faithful — see #37 for the long write-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-01 16:05:33 +02:00
parent 3361641655
commit 09e013b7bd
3 changed files with 166 additions and 3 deletions

View file

@ -46,6 +46,96 @@ Copy this block when adding a new issue:
# Active issues
## #37 — Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
**Status:** OPEN
**Severity:** LOW (cosmetic; doesn't affect gameplay)
**Filed:** 2026-05-01
**Component:** rendering / clothing / textures
**Description:** Every humanoid character (player + NPCs) wearing a coat
shows a visible skin-colored region at the top of the coat where retail
shows continuous coat fabric. From the back view: hair → skin stub →
coat top. In retail: hair → coat collar (no exposed skin). This was
originally reported as "head/neck protruding forward" — the apparent
forward shift is an optical illusion caused by the missing coat collar.
**Investigation 2026-05-01 (~3 hr session, conclusively ruled out
many hypotheses):**
What we ruled out:
- **Animation source.** `ACDREAM_USE_PLACEMENT_BASE=1` (force chars to
`Setup.PlacementFrames[Resting]` instead of `Animation.PartFrames[0]`)
→ stub still visible.
- **Backface culling / mesh winding.** `ACDREAM_NO_CULL=1` (disable
`glCullFace` entirely) → stub still visible.
- **Palette overlay (SubPalettes).** `ACDREAM_NO_PALETTE_OVERLAY=1`
(skip `ComposePalette`) → stub still visible (other colors broke
as expected — confirms overlay was firing). Bug is NOT a body-skin
SubPalette being mis-applied to coat fabric.
- **Bug source = part 16 (head).** `ACDREAM_HIDE_PART=16` → head goes
away, stub remains UNCHANGED (clean coat top with same shape).
Stub is NOT from head GfxObj polygons.
- **Per-part placement frame Origin.** `ACDREAM_NUDGE_Y=-0.1` confirmed
`+Y = forward` in body-local; head Origin (0, 0.013, 1.587) places
head correctly relative to spine. Math checks out.
What we confirmed (data is correct):
- Player Setup `0x02000001` (Aluvian Male), 34 parts.
- Server (ACE) sends `animParts=34 texChanges=12 subPalettes=10`.
- Part 9 (upper torso/coat) has gfx `0x0100120D` after AnimPartChange.
- Part 9 has 2 surfaces, BOTH covered by 2 TextureChanges
(`oldTex=0x050003D5→0x05001AFE`, `oldTex=0x050003D4→0x05001AFC`).
- Stub IS from part 9: `ACDREAM_HIDE_PART=9` → entire torso (including
stub region) disappears.
- Per-part composition formula (`Scale × Rotation × Translation`)
matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's
`Frame::combine` at `0x00518FD0`.
**Remaining hypothesis space (untested):**
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.
**Files (diagnostic env vars committed for next-session reuse):**
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
`ACDREAM_NO_CULL` env var
- `src/AcDream.App/Rendering/GameWindow.cs``ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
- `src/AcDream.App/Rendering/TextureCache.cs:159-204``DecodeFromDats`
is the texture decode entry. Compare against
`references/WorldBuilder-ACME-Edition/.../TextureHelpers.cs`.
**Reproduction:**
```powershell
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_DEVTOOLS = "1"
# normal launch — visible from chase camera looking at +Acdream's back
```
Stub is visible on +Acdream and on every NPC humanoid (Pathwarden,
Town Crier, Shopkeeper Renald, etc.).
**Acceptance:** Side-by-side retail + acdream rendering of +Acdream
shows coat extending up to chin level on both. No exposed skin
between hair and coat.
## #L.1 — Hotbar UI panel
**Status:** OPEN

View file

@ -168,6 +168,10 @@ public sealed class GameWindow : IDisposable
// Keep the experimental path available for DAT archaeology only.
private readonly bool _enableSkyPesDebug =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal);
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
private static readonly int s_hidePartIndex =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1;
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
@ -1912,6 +1916,17 @@ public sealed class GameWindow : IDisposable
// then proceed with the normal upload loop.
var parts = new List<AcDream.Core.World.MeshRef>(flat);
var animPartChanges = spawn.AnimPartChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>();
// Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups
// gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is
// sending coverage for the neck (part 9 for Aluvian Male) etc.
bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal)
&& setup.Parts.Count >= 10;
if (dumpClothing)
{
Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} APC={animPartChanges.Count} ===");
foreach (var c in animPartChanges)
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
}
foreach (var change in animPartChanges)
{
if (change.PartIndex < parts.Count)
@ -1932,6 +1947,45 @@ public sealed class GameWindow : IDisposable
// to get a texture decoded with the replacement SurfaceTexture
// substituted inside the Surface's decode chain.
var textureChanges = spawn.TextureChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>();
if (dumpClothing)
{
Console.WriteLine($" TextureChanges count={textureChanges.Count}");
foreach (var tc in textureChanges)
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
// For each part (post-AnimPartChange), dump its Surface chain so we
// can see which OrigTextureIds the part references and check which
// are covered by our TextureChanges.
var tcByPart = new Dictionary<int, HashSet<uint>>();
foreach (var tc in textureChanges)
{
if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet<uint>(); tcByPart[tc.PartIndex] = set; }
set.Add(tc.OldTexture);
}
for (int pi = 0; pi < parts.Count; pi++)
{
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
if (pgfx is null) continue;
if (pgfx.Surfaces.Count == 0) continue;
tcByPart.TryGetValue(pi, out var coveredOldTex);
int matched = 0;
int unmatched = 0;
var unmatchedList = new List<string>();
foreach (var surfQid in pgfx.Surfaces)
{
uint surfId = (uint)surfQid;
var surf = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
if (surf is null) continue;
uint origTex = (uint)surf.OrigTextureId;
if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++;
else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); }
}
if (pgfx.Surfaces.Count > 0)
Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}");
foreach (var s in unmatchedList)
Console.WriteLine($" UNMATCHED {s}");
}
}
Dictionary<int, Dictionary<uint, uint>>? resolvedOverridesByPart = null;
if (textureChanges.Count > 0)
{
@ -6036,6 +6090,10 @@ public sealed class GameWindow : IDisposable
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
if (s_hidePartIndex >= 0 && i == s_hidePartIndex && partCount >= 10)
{
continue;
}
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,

View file

@ -207,6 +207,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
}
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
@ -268,9 +273,19 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
// visible polygons to be culled, especially around the neck/coat seam).
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
else
{
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
}
foreach (var (key, grp) in _groups)
{