feat(net+app): TextureChanges applied via Surface→OrigTex resolution (Phase 5a)

Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.

The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.

Diagnosed via Phase 1 systematic debugging with resolve-level logging:

  live: spawn +Acdream texChanges=20
  live:   texChange part=0 old=0x05000BB0 new=0x0500025D
  ...
  live:   resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
  live:   resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
  ... 10/10 lines [MATCH]

The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.

Fix:
  - TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
    loads the base Surface dat for its color/flags/palette, but
    substitutes the override SurfaceTexture id in the decode chain.
    Caches under a (surfaceId, origTexOverride) composite key.
  - MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
    Surface id, value = replacement OrigTextureId. Null means no
    overrides.
  - GameWindow.OnLiveEntitySpawned now does TWO passes when texture
    changes are present:
      1. Group the raw server changes by PartIndex into (oldOrigTex →
         newOrigTex) dicts
      2. For each affected part's post-animPartChange GfxObj, iterate
         its Surfaces list, resolve each Surface → OrigTextureId, and
         if that matches a raw change's oldOrigTex, write an entry
         Surface id → newOrigTex into the final override map
  - StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
    call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.

Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.

Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.

Cross-referenced against two new references this session:
  * references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
    Chorizite.ACProtocol.git on user's suggestion) — confirms the
    ObjDesc field order and PackedDword-of-known-type convention.
  * references/WorldBuilder/... (already in repo) — confirms the
    Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
    P8/INDEX16 palette decode path.

Tests: 77 core + 83 net = 160, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 16:22:23 +02:00
parent 6ab24c9982
commit b69d776179
6 changed files with 255 additions and 22 deletions

View file

@ -12,6 +12,12 @@ public sealed unsafe class TextureCache : IDisposable
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
/// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
/// value = GL texture handle.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
private uint _magentaHandle;
public TextureCache(GL gl, DatCollection dats)
@ -30,27 +36,54 @@ public sealed unsafe class TextureCache : IDisposable
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
return h;
var decoded = DecodeFromDats(surfaceId);
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
}
private DecodedTexture DecodeFromDats(uint surfaceId)
/// <summary>
/// Get or upload a texture for a Surface id but with its
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.
/// The Surface's other properties (type flags, color, translucency,
/// clipmap handling, default palette) are preserved — only the
/// SurfaceTexture lookup is swapped. This is how the server's
/// CreateObject.TextureChanges are applied at render time.
/// Caches under a composite key so multiple entities can share.
/// </summary>
public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId)
{
var key = (surfaceId, overrideOrigTextureId);
if (_handlesByOverridden.TryGetValue(key, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId);
h = UploadRgba8(decoded);
_handlesByOverridden[key] = h;
return h;
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride)
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
return DecodedTexture.Magenta;
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
// instead of a texture chain. Without this, surfaces with no texture
// would fall through to the magenta fallback. Translucency is honored
// so Base1Solid|Translucent surfaces with Translucency=1.0 become
// alpha=0, which the mesh shader's discard cutout makes invisible.
// instead of a texture chain. Overrides are irrelevant here — there's
// no texture chain to swap — so the override is ignored for solid-color
// surfaces. Translucency is honored so Base1Solid|Translucent surfaces
// with Translucency=1.0 become alpha=0, which the mesh shader's discard
// cutout makes invisible.
if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0)
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
var surfaceTexture = _dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
// Use the override SurfaceTexture id when present, otherwise the
// Surface's native OrigTextureId. This is the whole point of the
// override path — caller says "this Surface, but with a different
// SurfaceTexture underneath."
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
return DecodedTexture.Magenta;
@ -99,6 +132,9 @@ public sealed unsafe class TextureCache : IDisposable
foreach (var h in _handlesBySurfaceId.Values)
_gl.DeleteTexture(h);
_handlesBySurfaceId.Clear();
foreach (var h in _handlesByOverridden.Values)
_gl.DeleteTexture(h);
_handlesByOverridden.Clear();
if (_magentaHandle != 0)
{
_gl.DeleteTexture(_magentaHandle);