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:
parent
b69d776179
commit
733f8ff601
6 changed files with 238 additions and 14 deletions
38
CLAUDE.md
38
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
|
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
|
`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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,23 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
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
|
var entity = new AcDream.Core.World.WorldEntity
|
||||||
{
|
{
|
||||||
Id = _liveEntityIdCounter++,
|
Id = _liveEntityIdCounter++,
|
||||||
|
|
@ -717,6 +734,7 @@ public sealed class GameWindow : IDisposable
|
||||||
Position = worldPos,
|
Position = worldPos,
|
||||||
Rotation = rot,
|
Rotation = rot,
|
||||||
MeshRefs = meshRefs,
|
MeshRefs = meshRefs,
|
||||||
|
PaletteOverride = paletteOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||||
|
|
|
||||||
|
|
@ -99,13 +99,27 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
|
||||||
|
|
||||||
foreach (var sub in subMeshes)
|
foreach (var sub in subMeshes)
|
||||||
{
|
{
|
||||||
// Honor per-part surface overrides from CreateObject's
|
// Pick the right TextureCache path based on what
|
||||||
// TextureChanges. The map is Surface id (0x08) → replacement
|
// overrides this entity carries:
|
||||||
// OrigTextureId (0x05 SurfaceTexture). A hit means "decode
|
// - Neither → plain GetOrUpload
|
||||||
// this Surface but swap its OrigTextureId for the override."
|
// - 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;
|
uint tex;
|
||||||
if (meshRef.SurfaceOverrides is not null
|
if (entity.PaletteOverride is not null)
|
||||||
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out var overrideOrigTex))
|
{
|
||||||
|
tex = _textures.GetOrUploadWithPaletteOverride(
|
||||||
|
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
|
||||||
|
}
|
||||||
|
else if (hasOrigTexOverride)
|
||||||
{
|
{
|
||||||
tex = _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
|
tex = _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// src/AcDream.App/Rendering/TextureCache.cs
|
// src/AcDream.App/Rendering/TextureCache.cs
|
||||||
using AcDream.Core.Textures;
|
using AcDream.Core.Textures;
|
||||||
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using Silk.NET.OpenGL;
|
using Silk.NET.OpenGL;
|
||||||
|
|
@ -18,6 +19,14 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
/// value = GL texture handle.
|
/// value = GL texture handle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
|
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;
|
private uint _magentaHandle;
|
||||||
|
|
||||||
public TextureCache(GL gl, DatCollection dats)
|
public TextureCache(GL gl, DatCollection dats)
|
||||||
|
|
@ -36,7 +45,7 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
|
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
|
||||||
return h;
|
return h;
|
||||||
|
|
||||||
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null);
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
||||||
h = UploadRgba8(decoded);
|
h = UploadRgba8(decoded);
|
||||||
_handlesBySurfaceId[surfaceId] = h;
|
_handlesBySurfaceId[surfaceId] = h;
|
||||||
return h;
|
return h;
|
||||||
|
|
@ -57,13 +66,57 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
if (_handlesByOverridden.TryGetValue(key, out var h))
|
if (_handlesByOverridden.TryGetValue(key, out var h))
|
||||||
return h;
|
return h;
|
||||||
|
|
||||||
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId);
|
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
|
||||||
h = UploadRgba8(decoded);
|
h = UploadRgba8(decoded);
|
||||||
_handlesByOverridden[key] = h;
|
_handlesByOverridden[key] = h;
|
||||||
return 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);
|
var surface = _dats.Get<Surface>(surfaceId);
|
||||||
if (surface is null)
|
if (surface is null)
|
||||||
|
|
@ -79,9 +132,7 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
|
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
|
||||||
|
|
||||||
// Use the override SurfaceTexture id when present, otherwise the
|
// Use the override SurfaceTexture id when present, otherwise the
|
||||||
// Surface's native OrigTextureId. This is the whole point of the
|
// Surface's native OrigTextureId.
|
||||||
// override path — caller says "this Surface, but with a different
|
|
||||||
// SurfaceTexture underneath."
|
|
||||||
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
|
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
|
||||||
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
||||||
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
||||||
|
|
@ -91,14 +142,59 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
if (rs is null)
|
if (rs is null)
|
||||||
return DecodedTexture.Magenta;
|
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)
|
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
||||||
: null;
|
: 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.
|
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
|
||||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
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)
|
private uint UploadRgba8(DecodedTexture decoded)
|
||||||
|
|
@ -135,6 +231,9 @@ public sealed unsafe class TextureCache : IDisposable
|
||||||
foreach (var h in _handlesByOverridden.Values)
|
foreach (var h in _handlesByOverridden.Values)
|
||||||
_gl.DeleteTexture(h);
|
_gl.DeleteTexture(h);
|
||||||
_handlesByOverridden.Clear();
|
_handlesByOverridden.Clear();
|
||||||
|
foreach (var h in _handlesByPalette.Values)
|
||||||
|
_gl.DeleteTexture(h);
|
||||||
|
_handlesByPalette.Clear();
|
||||||
if (_magentaHandle != 0)
|
if (_magentaHandle != 0)
|
||||||
{
|
{
|
||||||
_gl.DeleteTexture(_magentaHandle);
|
_gl.DeleteTexture(_magentaHandle);
|
||||||
|
|
|
||||||
46
src/AcDream.Core/World/PaletteOverride.cs
Normal file
46
src/AcDream.Core/World/PaletteOverride.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-specified palette composition for a live-mode entity. Gets
|
||||||
|
/// handed to <see cref="StaticMeshRenderer"/> 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Semantics (ported from Chorizite.ACProtocol.Types.Subpalette docs
|
||||||
|
/// and confirmed against ACE's CalculateObjDesc):</b>
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>BasePaletteId</c> — the entity's base palette dat id
|
||||||
|
/// (<c>0x04XXXXXX</c>). Comes from <c>ObjDesc.Palette</c>. Acts
|
||||||
|
/// as the starting point before subpalette overlays are applied.</item>
|
||||||
|
/// <item>Each overlay entry <c>(SubPaletteId, Offset, Length)</c>
|
||||||
|
/// means "copy the first <c>Length*8</c> colors from the
|
||||||
|
/// subpalette into the base palette starting at index
|
||||||
|
/// <c>Offset*8</c>." Length=0 is a sentinel meaning "entire
|
||||||
|
/// palette" (Chorizite documents it as defaulting to <c>256*8</c>
|
||||||
|
/// which is effectively everything).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PaletteOverride(
|
||||||
|
uint BasePaletteId,
|
||||||
|
IReadOnlyList<PaletteOverride.SubPaletteRange> SubPalettes)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One overlay range in a <see cref="PaletteOverride"/>. Offset and
|
||||||
|
/// Length are the raw bytes the server sent; callers multiply by 8
|
||||||
|
/// to get palette-index units.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SubPaletteRange(
|
||||||
|
uint SubPaletteId,
|
||||||
|
byte Offset,
|
||||||
|
byte Length);
|
||||||
|
}
|
||||||
|
|
@ -9,4 +9,13 @@ public sealed class WorldEntity
|
||||||
public required Vector3 Position { get; init; }
|
public required Vector3 Position { get; init; }
|
||||||
public required Quaternion Rotation { get; init; }
|
public required Quaternion Rotation { get; init; }
|
||||||
public required IReadOnlyList<MeshRef> MeshRefs { get; init; }
|
public required IReadOnlyList<MeshRef> MeshRefs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public PaletteOverride? PaletteOverride { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue