diff --git a/CLAUDE.md b/CLAUDE.md
index 3003326..6647539 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,3 +48,41 @@ Things you should just do without asking:
Before claiming a phase or sub-step is done: run `dotnet build` and
`dotnet test` green, commit with a message that explains the "why", update
memory if there's a durable lesson, and move to the next todo item.
+
+## Reference repos: check ALL FOUR, not just one
+
+When researching a protocol detail, dat format, rendering algorithm, or
+any "how does AC do X" question, **check all four of the vendored
+references in `references/`** before committing to an approach. Do not
+settle on the first hit and move on — cross-reference at least two of
+these, ideally all four:
+
+- **`references/ACE/`** — ACEmulator server. Authority on the wire
+ protocol (packet framing, ISAAC, game message opcodes, serialization
+ order). The things a server has to know to parse and produce bytes.
+- **`references/ACViewer/`** — MonoGame-based dat viewer that actually
+ renders characters + world. Authority on the client-side visual
+ pipeline: ObjDesc application, palette overlays, texture decoding
+ for the palette-indexed formats. See
+ `ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
+ subpalette overlay algorithm.
+- **`references/WorldBuilder/`** — C# + Silk.NET dat editor. Exact-stack
+ match to acdream for rendering approaches: terrain blending, texture
+ atlases, shader patterns. Most useful for "how do I do this GL thing
+ with Silk.NET on net10 idiomatically?" Less useful for protocol or
+ character appearance (dat editor, not game client).
+- **`references/Chorizite.ACProtocol/`** — clean-room C# protocol
+ library generated from a protocol XML description. Useful sanity check
+ on field order, packed-dword conventions, type-prefix handling. The
+ generated Types/*.cs files have accurate field comments (e.g. "If
+ it is 0, it defaults to 256*8") that ACE's server-side code doesn't.
+- **`references/holtburger/`** — Rust AC client crate. Cross-references
+ handshake quirks, race delays, and per-message encoding decisions
+ that ACE doesn't document because it's server-side.
+
+Pattern: when you encounter an unknown behavior, grep all four for the
+relevant term, read each hit, and compose a multi-source understanding
+BEFORE writing acdream code. A single reference can be misleading; the
+intersection of all four is almost always the truth. The user has
+repeatedly had to remind me about this when I narrowly searched one ref
+and missed obvious answers in another.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 4cc21dc..4404954 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -710,6 +710,23 @@ public sealed class GameWindow : IDisposable
return;
}
+ // Build optional per-entity palette override from the server's base
+ // palette + subpalette overlays. The renderer applies these to
+ // palette-indexed textures (PFID_P8 / PFID_INDEX16) to get per-entity
+ // skin/hair/body colors and statue stone recoloring. Non-palette
+ // textures ignore the override.
+ AcDream.Core.World.PaletteOverride? paletteOverride = null;
+ if (spawn.SubPalettes is { Count: > 0 } spList)
+ {
+ var ranges = new AcDream.Core.World.PaletteOverride.SubPaletteRange[spList.Count];
+ for (int i = 0; i < spList.Count; i++)
+ ranges[i] = new AcDream.Core.World.PaletteOverride.SubPaletteRange(
+ spList[i].SubPaletteId, spList[i].Offset, spList[i].Length);
+ paletteOverride = new AcDream.Core.World.PaletteOverride(
+ BasePaletteId: spawn.BasePaletteId ?? 0,
+ SubPalettes: ranges);
+ }
+
var entity = new AcDream.Core.World.WorldEntity
{
Id = _liveEntityIdCounter++,
@@ -717,6 +734,7 @@ public sealed class GameWindow : IDisposable
Position = worldPos,
Rotation = rot,
MeshRefs = meshRefs,
+ PaletteOverride = paletteOverride,
};
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs
index 79ccafc..e9d878f 100644
--- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs
+++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs
@@ -99,13 +99,27 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
foreach (var sub in subMeshes)
{
- // Honor per-part surface overrides from CreateObject's
- // TextureChanges. The map is Surface id (0x08) → replacement
- // OrigTextureId (0x05 SurfaceTexture). A hit means "decode
- // this Surface but swap its OrigTextureId for the override."
+ // Pick the right TextureCache path based on what
+ // overrides this entity carries:
+ // - Neither → plain GetOrUpload
+ // - Per-part texture change only → GetOrUploadWithOrigTextureOverride
+ // - Entity palette override (optionally + texture change)
+ // → GetOrUploadWithPaletteOverride, which handles both
+ //
+ // Palette overrides are per-entity (shared across all parts);
+ // texture overrides are per-part via meshRef.SurfaceOverrides.
+ uint overrideOrigTex = 0;
+ bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
+ && meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
+ uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
+
uint tex;
- if (meshRef.SurfaceOverrides is not null
- && meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out var overrideOrigTex))
+ if (entity.PaletteOverride is not null)
+ {
+ tex = _textures.GetOrUploadWithPaletteOverride(
+ sub.SurfaceId, origTexOverride, entity.PaletteOverride);
+ }
+ else if (hasOrigTexOverride)
{
tex = _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs
index f3d725a..c176fc8 100644
--- a/src/AcDream.App/Rendering/TextureCache.cs
+++ b/src/AcDream.App/Rendering/TextureCache.cs
@@ -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.
///
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
+ ///
+ /// 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.
+ ///
+ 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)
+ ///
+ /// Full Phase 5 override: for palette-indexed textures (PFID_P8 /
+ /// PFID_INDEX16), applies 's
+ /// subpalette overlays on top of the texture's default palette
+ /// before decoding. Non-palette formats ignore the palette override.
+ /// Also honors if non-null.
+ ///
+ 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;
+ }
+
+ ///
+ /// Cheap 64-bit hash over a palette override's identity so two
+ /// entities with the same palette setup share a decode.
+ ///
+ 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(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(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(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);
+ }
+
+ ///
+ /// 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).
+ ///
+ 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(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);
diff --git a/src/AcDream.Core/World/PaletteOverride.cs b/src/AcDream.Core/World/PaletteOverride.cs
new file mode 100644
index 0000000..58451fc
--- /dev/null
+++ b/src/AcDream.Core/World/PaletteOverride.cs
@@ -0,0 +1,46 @@
+namespace AcDream.Core.World;
+
+///
+/// Server-specified palette composition for a live-mode entity. Gets
+/// handed to so the texture cache can
+/// compose the effective palette (base with sub-palette overlays) at
+/// decode time and use it when decoding palette-indexed textures
+/// (PFID_P8 / PFID_INDEX16). Non-palette textures ignore this entirely.
+///
+///
+/// Semantics (ported from Chorizite.ACProtocol.Types.Subpalette docs
+/// and confirmed against ACE's CalculateObjDesc):
+///
+///
+/// - BasePaletteId — the entity's base palette dat id
+/// (0x04XXXXXX). Comes from ObjDesc.Palette. Acts
+/// as the starting point before subpalette overlays are applied.
+/// - Each overlay entry (SubPaletteId, Offset, Length)
+/// means "copy the first Length*8 colors from the
+/// subpalette into the base palette starting at index
+/// Offset*8." Length=0 is a sentinel meaning "entire
+/// palette" (Chorizite documents it as defaulting to 256*8
+/// which is effectively everything).
+///
+///
+///
+/// Palette overlays are per-entity, not per-part: all parts of a
+/// character share the same composed palette (skin color is consistent
+/// across the whole body even if different body parts use different
+/// meshes).
+///
+///
+public sealed record PaletteOverride(
+ uint BasePaletteId,
+ IReadOnlyList SubPalettes)
+{
+ ///
+ /// One overlay range in a . Offset and
+ /// Length are the raw bytes the server sent; callers multiply by 8
+ /// to get palette-index units.
+ ///
+ public readonly record struct SubPaletteRange(
+ uint SubPaletteId,
+ byte Offset,
+ byte Length);
+}
diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs
index a02e5bf..705a5d0 100644
--- a/src/AcDream.Core/World/WorldEntity.cs
+++ b/src/AcDream.Core/World/WorldEntity.cs
@@ -9,4 +9,13 @@ public sealed class WorldEntity
public required Vector3 Position { get; init; }
public required Quaternion Rotation { get; init; }
public required IReadOnlyList MeshRefs { get; init; }
+
+ ///
+ /// Optional per-entity palette override (server-specified base +
+ /// subpalette overlays). When non-null, applies to every palette-
+ /// indexed texture on this entity. Used for character skin/hair
+ /// colors, creature recolors (e.g. stone-colored drudge statue),
+ /// and team colors. Non-palette-indexed textures ignore this field.
+ ///
+ public PaletteOverride? PaletteOverride { get; init; }
}