From 733f8ff60188d358dc8db5fc6994ea46a3c7aac0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 16:30:08 +0200 Subject: [PATCH] feat(net+app): SubPalette overlays applied to palette-indexed textures (Phase 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 38 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 18 +++ .../Rendering/StaticMeshRenderer.cs | 26 +++- src/AcDream.App/Rendering/TextureCache.cs | 115 ++++++++++++++++-- src/AcDream.Core/World/PaletteOverride.cs | 46 +++++++ src/AcDream.Core/World/WorldEntity.cs | 9 ++ 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 src/AcDream.Core/World/PaletteOverride.cs 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; } }