feat(net+app): SubPalette overlays applied to palette-indexed textures (Phase 5b)

Implements the other half of ObjDesc: SubPalettes (palette-range
overlays) that repaint palette-indexed textures with per-entity color
schemes. Ported algorithm from ACViewer Render/TextureCache.IndexToColor
after the user pointed out I was prematurely implementing from scratch
instead of checking all the reference repos.

The Nullified Statue of a Drudge sends (setup=0x020007DD with a drudge
GfxObj animPart replacing part 1, plus 2 texChanges targeted at part 1,
plus 1 subpalette id=0x04001351 offset=0 length=0). The TextureChanges
swap fine detail surfaces; the SubPalette with length=0 ("entire palette"
per Chorizite docs) remaps the drudge's flesh-tone palette to stone.
Without this commit, the statue looked like a normal flesh drudge
because palette-indexed textures decoded with the base flesh palette.

Added:
  - Core/World/PaletteOverride.cs: per-entity record carrying
    BasePaletteId + a list of (SubPaletteId, Offset, Length) range
    overlays. Documents the "offset/length are wire-scaled by 8"
    convention and the "length=0 means whole palette" sentinel.
  - WorldEntity.PaletteOverride nullable field. Per-entity (same across
    all parts), in contrast to MeshRef.SurfaceOverrides which is per-part.
  - TextureCache.GetOrUploadWithPaletteOverride: new entry point that
    composes the effective palette at decode time. Composite cache key
    is (surfaceId, origTexOverride, paletteHash) so entities with
    equivalent palette setups share the GL texture.
  - ComposePalette: ports ACViewer's IndexToColor overlay loop:
      for each subpalette sp:
          startIdx = sp.Offset * 8             // multiply back from wire
          count = sp.Length == 0 ? 2048 : sp.Length * 8   // sentinel
          for j in [0, count):
              composed[j + startIdx] = subPal.Colors[j + startIdx]
    Critical detail: copies from the SAME offset in the sub palette, not
    from [0]. Both base and sub are treated as full palettes sharing an
    index space.
  - StaticMeshRenderer.Draw: three-way switch on (entity.PaletteOverride,
    meshRef.SurfaceOverrides) picks the right TextureCache path:
      - Both → palette override (it handles origTex override internally)
      - Only tex override → GetOrUploadWithOrigTextureOverride
      - Neither → plain GetOrUpload
  - GameWindow.OnLiveEntitySpawned: builds PaletteOverride from
    spawn.BasePaletteId + spawn.SubPalettes when the server sent any.

Reference note: the user asked "but I mean THIS MUST BE IN WORLDBUILDER"
which was the right push. WorldBuilder is actually a dat VIEWER and its
ClothingTableBrowserViewModel is a 10-line stub — it doesn't apply
palette overlays because it doesn't need to. The actual algorithm lives
in ACViewer (a MonoGame character viewer), which I should have checked
earlier. CLAUDE.md updated with a standing rule: always cross-reference
all four of references/ACE, ACViewer, WorldBuilder, Chorizite.ACProtocol,
plus holtburger. A single reference can be misleading; the intersection
is usually the truth.

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:30:08 +02:00
parent b69d776179
commit 733f8ff601
6 changed files with 238 additions and 14 deletions

View file

@ -1,5 +1,6 @@
// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
@ -18,6 +19,14 @@ public sealed unsafe class TextureCache : IDisposable
/// value = GL texture handle.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
/// <summary>
/// Composite cache for palette-overridden entries (Phase 5 SubPalettes).
/// Key = (baseSurfaceId, origTexOverride, paletteHash), value = handle.
/// paletteHash is a cheap combined hash of the PaletteOverride's ids +
/// offsets + lengths so two entities with equivalent palette setups
/// share the same decoded texture.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle;
public TextureCache(GL gl, DatCollection dats)
@ -36,7 +45,7 @@ public sealed unsafe class TextureCache : IDisposable
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null);
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
@ -57,13 +66,57 @@ public sealed unsafe class TextureCache : IDisposable
if (_handlesByOverridden.TryGetValue(key, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId);
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
h = UploadRgba8(decoded);
_handlesByOverridden[key] = h;
return h;
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride)
/// <summary>
/// Full Phase 5 override: for palette-indexed textures (PFID_P8 /
/// PFID_INDEX16), applies <paramref name="paletteOverride"/>'s
/// subpalette overlays on top of the texture's default palette
/// before decoding. Non-palette formats ignore the palette override.
/// Also honors <paramref name="overrideOrigTextureId"/> if non-null.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
{
ulong hash = HashPaletteOverride(paletteOverride);
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, hash);
if (_handlesByPalette.TryGetValue(key, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
h = UploadRgba8(decoded);
_handlesByPalette[key] = h;
return h;
}
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode.
/// </summary>
private static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry.
ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis
const ulong prime = 0x100000001B3UL;
h = (h ^ p.BasePaletteId) * prime;
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
return h;
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride)
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
@ -79,9 +132,7 @@ public sealed unsafe class TextureCache : IDisposable
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
// 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."
// Surface's native OrigTextureId.
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
@ -91,14 +142,59 @@ public sealed unsafe class TextureCache : IDisposable
if (rs is null)
return DecodedTexture.Magenta;
Palette? palette = rs.DefaultPaletteId != 0
// Start with the texture's default palette, then apply overlays.
// ACViewer's Render/TextureCache.IndexToColor does the same and never
// consults ObjDesc.BasePaletteId for palette-indexed textures — the
// RenderSurface's own default palette is the starting point.
Palette? basePalette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
: null;
Palette? effectivePalette = basePalette;
if (paletteOverride is not null && basePalette is not null && paletteOverride.SubPalettes.Count > 0)
{
effectivePalette = ComposePalette(basePalette, paletteOverride);
}
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
return SurfaceDecoder.DecodeRenderSurface(rs, palette, isClipMap);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
}
/// <summary>
/// Build a composite palette by copying subpalette ranges into a
/// mutable copy of the base. Ported from ACViewer's
/// Render/TextureCache.IndexToColor, with network-side Offset/Length
/// multiplied by 8 to recover the raw palette-index units (ACE's
/// writer divides by 8 before writing).
/// </summary>
private Palette ComposePalette(Palette basePalette, PaletteOverride paletteOverride)
{
var composed = new Palette();
composed.Colors.AddRange(basePalette.Colors);
foreach (var sp in paletteOverride.SubPalettes)
{
var subPal = _dats.Get<Palette>(sp.SubPaletteId);
if (subPal is null) continue;
int startIdx = sp.Offset * 8;
// Length == 0 is the sentinel for "entire palette" per
// Chorizite.ACProtocol.Types.Subpalette docs. Use a value
// large enough to cover any real palette; we clamp below.
int count = sp.Length == 0 ? 2048 : sp.Length * 8;
for (int j = 0; j < count; j++)
{
int idx = startIdx + j;
if (idx >= composed.Colors.Count || idx >= subPal.Colors.Count)
break;
composed.Colors[idx] = subPal.Colors[idx];
}
}
return composed;
}
private uint UploadRgba8(DecodedTexture decoded)
@ -135,6 +231,9 @@ public sealed unsafe class TextureCache : IDisposable
foreach (var h in _handlesByOverridden.Values)
_gl.DeleteTexture(h);
_handlesByOverridden.Clear();
foreach (var h in _handlesByPalette.Values)
_gl.DeleteTexture(h);
_handlesByPalette.Clear();
if (_magentaHandle != 0)
{
_gl.DeleteTexture(_magentaHandle);