From caa538059986e03cb5879e8a3a60a3170af94444 Mon Sep 17 00:00:00 2001 From: Justin Baugh Date: Sun, 26 Jul 2026 15:40:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(drawing):=20PaletteResolver=20=E2=80=94=20?= =?UTF-8?q?shared=20legacy=20palette=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comhaigne's palette-resolution.md (transcribed from ChaosAssetManager) as the single source of truth all consumers (foscail, Brigid, dalib-ts, Taliesin) can share instead of re-deriving. Selects a palette; does not decode or draw. Scope this PR: .epf archive rules + the khan family — the immediate UI-extraction need, and conflict-free with existing code. - Contract: ResolvedPalette record (palette, paletteNumber, isLuminanceBlended, kind, ruleId), PaletteSourceKind {Table,Indexed,Fixed,Constant}, ArchiveProvider delegate for sibling access (no hardcoded paths). Instance-scoped caches (the spec's answer to CAM's 27-field static Reset()). - Rules: legend.dat (9 branches), setoa.dat (25 hand-mapped gui rules, order hazards preserved, field000 wart), roh.dat (efct/mefc), national/misc (sibling legend.pal), and khan (letter remap a->b/g->c/j->c/o->m/s->p, table letters with male/female override, body -> lowest palm, pants -> color0.tbl dye index 0). - Luminance >=1000 reported via the flag; the arithmetic stays in PaletteLookup. - 22 tests: first-match order hazards (dlgcre01/emot00/lsbackm/setup12), the four kinds, khan remap+override+luminance, sibling legend.pal, the field000 wart. Deferred (own follow-up): .hpf/.mpf/tileset rules — the tileset mpt +1 (spec) vs +2 (DALib RenderMap) keying needs reconciling against retail first. Then wire foscail's ConvertEpf to the resolver (supersedes PR #20) and agree the shared conformance fixture + ruleId strings with dalib-ts. Co-Authored-By: Imbas --- DALib.Tests/PaletteResolverTests.cs | 311 +++++++++++++++++++ DALib/Definitions/Enums.cs | 28 ++ DALib/Drawing/PaletteResolver.cs | 464 ++++++++++++++++++++++++++++ 3 files changed, 803 insertions(+) create mode 100644 DALib.Tests/PaletteResolverTests.cs create mode 100644 DALib/Drawing/PaletteResolver.cs diff --git a/DALib.Tests/PaletteResolverTests.cs b/DALib.Tests/PaletteResolverTests.cs new file mode 100644 index 0000000..3491c15 --- /dev/null +++ b/DALib.Tests/PaletteResolverTests.cs @@ -0,0 +1,311 @@ +using System.Text; +using DALib.Data; +using DALib.Definitions; +using DALib.Drawing; +using SkiaSharp; + +namespace DALib.Tests; + +/// +/// Coverage for against comhaigne's palette-resolution.md. The resolver +/// reads only entry names (never EPF pixels), so fixtures are archives of dummy .epf entries plus +/// real .pal/.tbl palette sources. Per the spec's testing guidance, assertions are on +/// RuleId / PaletteNumber / Kind / IsLuminanceBlended — the load-bearing +/// first-match order and keying — not on color arrays (except the field000 wart, which is about identity). +/// +public class PaletteResolverTests : IDisposable +{ + private readonly string ScratchDir; + private readonly List Opened = []; + + public PaletteResolverTests() => ScratchDir = Directory.CreateTempSubdirectory("dalib-resolver-").FullName; + + public void Dispose() + { + foreach (var archive in Opened) + archive.Dispose(); + + Directory.Delete(ScratchDir, true); + } + + // ── fixture builders ──────────────────────────────────────────────────────────────────────── + + // legacy archive layout: [count+1:i32] then per entry [start:i32][name:13 ascii NUL-padded], a + // final [end:i32], then concatenated entry data + private static byte[] BuildArchive(params (string Name, byte[] Data)[] entries) + { + const int NAME_LENGTH = 13; + + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms, Encoding.Default, true); + + writer.Write(entries.Length + 1); + var address = 4 + entries.Length * (4 + NAME_LENGTH) + 4; + + foreach (var (name, data) in entries) + { + writer.Write(address); + writer.Write(Encoding.ASCII.GetBytes(name.PadRight(NAME_LENGTH, '\0'))); + address += data.Length; + } + + writer.Write(address); + + foreach (var (_, data) in entries) + writer.Write(data); + + return ms.ToArray(); + } + + private static byte[] Pal(SKColor? markerAtIndex1 = null) + { + var palette = new Palette(); + + if (markerAtIndex1 is { } color) + palette[1] = color; + + using var ms = new MemoryStream(); + palette.Save(ms); + + return ms.ToArray(); + } + + private static (string, byte[]) Epf(string name) => (name, [0, 0, 0, 0]); // name-only; pixels unused + + private DataArchive Dat(params (string Name, byte[] Data)[] entries) + { + var path = Path.Combine(ScratchDir, $"{Guid.NewGuid():N}.dat"); + File.WriteAllBytes(path, BuildArchive(entries)); + var archive = DataArchive.FromFile(path, memoryMapped: false); + Opened.Add(archive); + + return archive; + } + + private static ArchiveProvider None => _ => null; + + private static ResolvedPalette? Resolve(string archiveName, DataArchive archive, string entryName, ArchiveProvider? provider = null, int frameIndex = 0) + { + var resolver = new PaletteResolver(archiveName, archive, provider ?? None); + + return resolver.Resolve(archive[entryName], frameIndex); + } + + // gui00..gui17 with a distinct marker per index, so a wrong constant shows up + private static (string, byte[])[] GuiPalettes() + => Enumerable.Range(0, 18) + .Select(i => ($"gui{i:D2}.pal", Pal(new SKColor((byte)i, 0, 0)))) + .ToArray(); + + // ── setoa: order hazards + constants ──────────────────────────────────────────────────────── + + [Theory] + [InlineData("dlgcre01a.epf", "setoa/dlgcre01", 8)] // must precede dlgcre (rule 12) + [InlineData("dlgcrexx.epf", "setoa/lback", 4)] + [InlineData("emot00.epf", "setoa/emot00", 0)] // must precede emot (rule 9) + [InlineData("emotxx.epf", "setoa/emot", 3)] + [InlineData("lsbackm1.epf", "setoa/lsbackm", 0)] // must precede lsback (rule 16) + [InlineData("lsbackx.epf", "setoa/lsback", 10)] + [InlineData("setup12.epf", "setoa/setup12", 0)] // must precede setup (rule 12) + [InlineData("setupxx.epf", "setoa/lback", 4)] + [InlineData("lg_stat.epf", "setoa/lg_", 15)] + [InlineData("album.epf", "setoa/album", 17)] + [InlineData("zzztest.epf", "setoa/default", 0)] + public void Setoa_FirstMatch_Order_And_Constants(string entryName, string ruleId, int paletteNumber) + { + (string, byte[])[] entries = [Epf(entryName), .. GuiPalettes()]; + var setoa = Dat(entries); + + var result = Resolve("setoa.dat", setoa, entryName); + + result.Should().NotBeNull(); + result!.RuleId.Should().Be(ruleId); + result.PaletteNumber.Should().Be(paletteNumber); + result.Kind.Should().Be(PaletteSourceKind.Constant); + } + + [Fact] + public void Setoa_Field_Wart_Forces_Field000_Over_Fielde00() + { + var real = new SKColor(10, 20, 30); + var stray = new SKColor(200, 100, 50); + + // both field000.pal and the stray fielde00.pal parse to id 0; field000 must win slot 0 + (string, byte[])[] entries = + [ + Epf("field0.epf"), + ("field000.pal", Pal(real)), + ("fielde00.pal", Pal(stray)), + .. GuiPalettes() + ]; + var setoa = Dat(entries); + + var result = Resolve("setoa.dat", setoa, "field0.epf"); + + result.Should().NotBeNull(); + result!.RuleId.Should().Be("setoa/field"); + result.Kind.Should().Be(PaletteSourceKind.Indexed); + result.Palette[1].Should().Be(real); + } + + // ── legend ────────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Legend_Branches_Resolve_By_Prefix() + { + var legend = Dat( + Epf("bkstory3.epf"), + Epf("field2.epf"), + Epf("skillfoo.epf"), + Epf("linexx.epf"), + Epf("f0bar.epf"), + Epf("staffx.epf"), + Epf("whatever.epf"), + ("backpal3.pal", Pal()), + ("field2.pal", Pal()), + ("legend01.pal", Pal()), + ("legend.pal", Pal()), + ("staff.pal", Pal())); + + Resolve("legend.dat", legend, "bkstory3.epf")!.Should().BeEquivalentTo(new { RuleId = "legend/bkstory", PaletteNumber = 3, Kind = PaletteSourceKind.Indexed }); + Resolve("legend.dat", legend, "field2.epf")!.RuleId.Should().Be("legend/field"); + Resolve("legend.dat", legend, "skillfoo.epf")!.Should().BeEquivalentTo(new { RuleId = "legend/skill", Kind = PaletteSourceKind.Fixed }); + Resolve("legend.dat", legend, "linexx.epf")!.RuleId.Should().Be("legend/line"); // rule 5 before rule 7's list + Resolve("legend.dat", legend, "f0bar.epf")!.RuleId.Should().Be("legend/f0"); + Resolve("legend.dat", legend, "staffx.epf")!.RuleId.Should().Be("legend/staff"); + Resolve("legend.dat", legend, "whatever.epf")!.RuleId.Should().Be("legend/default"); + } + + [Fact] + public void Legend_Item_Uses_Global_Icon_Numbering_Per_Frame() + { + // file 2, frame 0 -> global icon (2-1)*266 + 0 + 1 = 267 + var legend = Dat( + Epf("item002.epf"), + ("itempal.tbl", "267 4\n"u8.ToArray()), + ("item004.pal", Pal()), + ("item000.pal", Pal())); + + var result = Resolve("legend.dat", legend, "item002.epf", frameIndex: 0); + + result.Should().NotBeNull(); + result!.RuleId.Should().Be("legend/item"); + result.Kind.Should().Be(PaletteSourceKind.Table); + result.PaletteNumber.Should().Be(4); + } + + // ── roh ───────────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Roh_Efct_And_Mefc_Are_Table_Others_Unresolved() + { + var roh = Dat( + Epf("efct001.epf"), + Epf("mefc001.epf"), + Epf("randomxyz.epf"), + ("effpal.tbl", "1 2\n"u8.ToArray()), + ("eff002.pal", Pal()), + ("mefcpal.tbl", "1 3\n"u8.ToArray()), + ("mefc003.pal", Pal())); + + Resolve("roh.dat", roh, "efct001.epf")!.Should().BeEquivalentTo(new { RuleId = "roh/efct", PaletteNumber = 2, Kind = PaletteSourceKind.Table }); + Resolve("roh.dat", roh, "mefc001.epf")!.RuleId.Should().Be("roh/mefc"); + Resolve("roh.dat", roh, "randomxyz.epf").Should().BeNull(); + } + + // ── national / misc: sibling legend.pal ───────────────────────────────────────────────────── + + [Fact] + public void National_Uses_Sibling_Legend_Pal() + { + var legend = Dat(("legend.pal", Pal())); + var national = Dat(Epf("tmap0.epf")); + + var result = Resolve("national.dat", national, "tmap0.epf", provider: name => name.Equals("legend.dat", StringComparison.OrdinalIgnoreCase) ? legend : null); + + result.Should().NotBeNull(); + result!.RuleId.Should().Be("national/all"); + result.Kind.Should().Be(PaletteSourceKind.Fixed); + } + + [Fact] + public void National_Without_Sibling_Is_Unresolved() + { + var national = Dat(Epf("tmap0.epf")); + + Resolve("national.dat", national, "tmap0.epf").Should().BeNull(); + } + + // ── khan ──────────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Khan_Table_Letter_With_Gender_Override_And_Remap() + { + // palu.tbl: sprite 1 default->0, male(-1)->1, female(-2)->2 + var khanpal = Dat( + ("palu.tbl", "1 0\n1 1 -1\n1 2 -2\n"u8.ToArray()), + ("palu000.pal", Pal()), + ("palu001.pal", Pal()), + ("palu002.pal", Pal()), + ("palb.tbl", "1 5\n"u8.ToArray()), + ("palb005.pal", Pal())); + + ArchiveProvider provider = name => name.Equals("khanpal.dat", StringComparison.OrdinalIgnoreCase) ? khanpal : null; + + var parts = Dat(Epf("mu00101.epf"), Epf("wu00101.epf"), Epf("ma00101.epf")); + + // mu = male, letter u -> palu, sprite 1 (3-digit id, anim 01 dropped), male override -> 1 + Resolve("khanmad.dat", parts, "mu00101.epf", provider)!.Should().BeEquivalentTo(new { RuleId = "khan/letter", PaletteNumber = 1, Kind = PaletteSourceKind.Table }); + // wu = female override -> 2 + Resolve("khanmad.dat", parts, "wu00101.epf", provider)!.PaletteNumber.Should().Be(2); + // ma = letter a remaps to b -> palb, sprite 1 -> 5 + Resolve("khanmad.dat", parts, "ma00101.epf", provider)!.PaletteNumber.Should().Be(5); + } + + [Fact] + public void Khan_Body_Resolves_To_Lowest_Palm() + { + var khanpal = Dat(("palm3.pal", Pal()), ("palm7.pal", Pal())); + ArchiveProvider provider = name => name.Equals("khanpal.dat", StringComparison.OrdinalIgnoreCase) ? khanpal : null; + var body = Dat(Epf("mm00001.epf")); + + var result = Resolve("khanmad.dat", body, "mm00001.epf", provider); + + result.Should().NotBeNull(); + result!.RuleId.Should().Be("khan/body"); + result.PaletteNumber.Should().Be(3); // lowest available palm + } + + [Fact] + public void Khan_Table_Reports_Luminance_Blending_Above_1000() + { + // sprite 1 -> palette 1005: luminance-blended, real number 5 + var khanpal = Dat(("palu.tbl", "1 1005\n"u8.ToArray()), ("palu005.pal", Pal())); + ArchiveProvider provider = name => name.Equals("khanpal.dat", StringComparison.OrdinalIgnoreCase) ? khanpal : null; + var parts = Dat(Epf("mu00101.epf")); + + var result = Resolve("khanmad.dat", parts, "mu00101.epf", provider); + + result.Should().NotBeNull(); + result!.IsLuminanceBlended.Should().BeTrue(); + result.PaletteNumber.Should().Be(5); + } + + [Fact] + public void Khan_Without_Khanpal_Sibling_Is_Unresolved() + { + var parts = Dat(Epf("mu00101.epf")); + + Resolve("khanmad.dat", parts, "mu00101.epf").Should().BeNull(); + } + + // ── non-.epf is deferred (returns null) ───────────────────────────────────────────────────── + + [Fact] + public void NonEpf_Is_Not_Resolved_Here() + { + var archive = Dat(("stc00001.hpf", [0, 0, 0, 0]), ("stc.tbl", "1 1\n"u8.ToArray()), ("stc001.pal", Pal())); + + Resolve("ia.dat", archive, "stc00001.hpf").Should().BeNull(); + } +} diff --git a/DALib/Definitions/Enums.cs b/DALib/Definitions/Enums.cs index e99910c..6e7677d 100644 --- a/DALib/Definitions/Enums.cs +++ b/DALib/Definitions/Enums.cs @@ -106,6 +106,34 @@ public enum MpfIdleType NormalPlusOptional = 2 } +/// +/// How a rule sources the palette for a legacy asset. +/// +public enum PaletteSourceKind +{ + /// + /// A PaletteLookup over a <table>.tbl + <palette>*.pal set, keyed by the entry's + /// numeric identifier through the table (an extra level of indirection). + /// + Table, + + /// + /// A palette-number → palette map (no table), keyed by the entry's numeric identifier directly. + /// + Indexed, + + /// + /// A single named .pal entry. The rule always yields the same palette regardless of the entry. + /// + Fixed, + + /// + /// An indexed map keyed by a number written into the rule itself, not read from the entry (the hand-mapped + /// setoa.dat GUI family). + /// + Constant +} + /// /// Represents the different types of SPF formats /// diff --git a/DALib/Drawing/PaletteResolver.cs b/DALib/Drawing/PaletteResolver.cs new file mode 100644 index 0000000..38671e8 --- /dev/null +++ b/DALib/Drawing/PaletteResolver.cs @@ -0,0 +1,464 @@ +#region +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using DALib.Data; +using DALib.Definitions; +#endregion + +namespace DALib.Drawing; + +/// +/// Resolves which palette colors a legacy Dark Ages asset (.epf and the khan family here; other +/// formats to follow). An asset frame is a grid of palette indices; the palette lives elsewhere — in the +/// same archive, a sibling .dat, keyed sometimes by a lookup table and sometimes by a number only +/// the client knows. This is the implementation of comhaigne's palette-resolution.md specification +/// (transcribed from ChaosAssetManager). It selects a palette; it does not decode, draw, or animate. +/// +/// +/// Instance-scoped so its lookup caches live and die with the open archive (the spec's answer to CAM's +/// 27-field static Reset()). Sibling archives (khanpal.dat, legend.dat) are supplied +/// by the caller through an so the resolver names no filesystem paths. +/// +public sealed class PaletteResolver +{ + private readonly DataArchive Archive; + private readonly string ArchiveName; + private readonly ArchiveProvider Provider; + + private readonly Dictionary LookupCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary?> IndexedCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary FixedCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary SiblingCache = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Creates a resolver for one open archive. + /// + /// + /// The archive's file name (with or without the .dat extension). Dispatch is by this name. + /// + /// + /// The open archive the entries belong to. + /// + /// + /// Supplies sibling archives by file name (e.g. khanpal.dat, legend.dat). May return null; + /// the affected rules then resolve to null while every other rule still works. + /// + public PaletteResolver(string archiveName, DataArchive archive, ArchiveProvider provider) + { + ArchiveName = NormalizeArchiveName(archiveName); + Archive = archive; + Provider = provider; + } + + /// + /// Resolves the palette for an entry, or null if no rule matched (the caller should then fall back to a + /// manual picker). + /// + /// + /// The entry to resolve. + /// + /// + /// The frame within the entry. Used only by the legend item rule, whose palette changes between + /// frames of a single .epf; every other rule ignores it. + /// + public ResolvedPalette? Resolve(DataArchiveEntry entry, int frameIndex = 0) + { + var ext = Path.GetExtension(entry.EntryName) + .ToLowerInvariant(); + + // .spf/.efa/.pal carry their own colors and need no resolution. .hpf/.mpf/tilesets are specified but + // deferred here pending the mpt +1 (spec) vs +2 (DALib RenderMap) tile-keying reconciliation. + return ext == ".epf" ? ResolveEpf(entry, frameIndex) : null; + } + + private ResolvedPalette? ResolveEpf(DataArchiveEntry entry, int frameIndex) + => ArchiveName switch + { + "legend" => ResolveLegend(entry, frameIndex), + "national" => SiblingLegendPal("national/all"), + "misc" => SiblingLegendPal("misc/all"), + "roh" => ResolveRoh(entry), + "setoa" => ResolveSetoa(entry), + _ => ArchiveName.Contains("khan", StringComparison.OrdinalIgnoreCase) ? ResolveKhan(entry) : null + }; + + #region legend.dat + private ResolvedPalette? ResolveLegend(DataArchiveEntry entry, int frameIndex) + { + var name = BaseName(entry); + entry.TryGetNumericIdentifier(out var id); + + // order is load-bearing: `line` (rule 5) matches before rule 7's list, which also names it (dead there) + if (name.StartsWith("bkstory", StringComparison.Ordinal)) + return Indexed(Archive, "main", "backpal", id, "legend/bkstory"); + + if (name.StartsWith("item", StringComparison.Ordinal)) + return Table(Archive, "main", "itempal", "item", ((id - 1) * ITEMS_PER_SHEET) + frameIndex + 1, KhanPalOverrideType.None, "legend/item"); + + if (name.StartsWith("field", StringComparison.Ordinal)) + return Indexed(Archive, "main", "field", id, "legend/field"); + + if (name.StartsWith("skill", StringComparison.Ordinal) || name.StartsWith("spell", StringComparison.Ordinal)) + return Fixed(Archive, "main", "legend01.pal", "legend/skill"); + + if (name.StartsWith("line", StringComparison.Ordinal)) + return Fixed(Archive, "main", "legend01.pal", "legend/line"); + + if (name.StartsWith("f0", StringComparison.Ordinal)) + return Fixed(Archive, "main", "legend.pal", "legend/f0"); + + if (StartsWithAny(name, "clock01", "emo", "mask", "ms", "question", "rain", "snow", "woodbk")) + return Fixed(Archive, "main", "legend01.pal", "legend/emo"); + + if (name.StartsWith("staff", StringComparison.Ordinal)) + return Fixed(Archive, "main", "staff.pal", "legend/staff"); + + return Fixed(Archive, "main", "legend.pal", "legend/default"); + } + #endregion + + #region roh.dat + private ResolvedPalette? ResolveRoh(DataArchiveEntry entry) + { + var name = BaseName(entry); + entry.TryGetNumericIdentifier(out var id); + + if (name.StartsWith("efct", StringComparison.Ordinal)) + return Table(Archive, "main", "effpal", "eff", id, KhanPalOverrideType.None, "roh/efct"); + + if (name.StartsWith("mefc", StringComparison.Ordinal)) + return Table(Archive, "main", "mefcpal", "mefc", id, KhanPalOverrideType.None, "roh/mefc"); + + return null; + } + #endregion + + #region setoa.dat + private ResolvedPalette? ResolveSetoa(DataArchiveEntry entry) + { + var name = BaseName(entry); + entry.TryGetNumericIdentifier(out var id); + + // rule 1 is a real lookup; rules 2-25 are hand-mapped constants over gui*.pal. Order is load-bearing: + // more specific prefixes (dlgcre01, emot00, lsbackm, setup12..) precede their shorter cousins. + if (name.StartsWith("field", StringComparison.Ordinal)) + return Indexed(Archive, "main", "field", id, "setoa/field", forceFieldZero: true); + + if (name.StartsWith("dlgcre01", StringComparison.Ordinal)) return Constant(8, "setoa/dlgcre01"); + if (StartsWithAny(name, "gbicon02", "mernum")) return Constant(0, "setoa/gbicon02"); + if (StartsWithAny(name, "emot00", "emotdlg")) return Constant(0, "setoa/emot00"); + if (name.StartsWith("lsbackm", StringComparison.Ordinal)) return Constant(0, "setoa/lsbackm"); + if (StartsWithAny(name, "setup12", "setup13", "setup14")) return Constant(0, "setoa/setup12"); + if (StartsWithAny(name, "gbicon12", "orb")) return Constant(1, "setoa/gbicon12"); + if (StartsWithAny(name, "gbicon01", "gbicon03")) return Constant(2, "setoa/gbicon01"); + if (StartsWithAny(name, "emot", "equip02", "mouse")) return Constant(3, "setoa/emot"); + if (name.StartsWith("legends", StringComparison.Ordinal)) return Constant(3, "setoa/legends"); + if (name.StartsWith("nation", StringComparison.Ordinal)) return Constant(5, "setoa/nation"); + if (StartsWithAny(name, "lback", "dlgcre", "lod0", "setup")) return Constant(4, "setoa/lback"); + if (StartsWithAny(name, "skill0", "spell0")) return Constant(6, "setoa/skill0"); + if (name.StartsWith("lodbk", StringComparison.Ordinal)) return Constant(7, "setoa/lodbk"); + if (name.StartsWith("staff", StringComparison.Ordinal)) return Constant(9, "setoa/staff"); + if (StartsWithAny(name, "lsback", "lss")) return Constant(10, "setoa/lsback"); + if (name.StartsWith("leicon", StringComparison.Ordinal)) return Constant(10, "setoa/leicon"); + if (name.StartsWith("ldi", StringComparison.Ordinal)) return Constant(11, "setoa/ldi"); + if (StartsWithAny(name, "lwmap", "tmapv")) return Constant(12, "setoa/lwmap"); + if (StartsWithAny(name, "bw_back", "bw_check")) return Constant(13, "setoa/bw_back"); + if (StartsWithAny(name, "kdesc", "key", "khotkey")) return Constant(14, "setoa/kdesc"); + if (name.StartsWith("lg_", StringComparison.Ordinal)) return Constant(15, "setoa/lg_"); + if (name.StartsWith("bw_flag", StringComparison.Ordinal)) return Constant(16, "setoa/bw_flag"); + if (name.StartsWith("album_b", StringComparison.Ordinal) || name.Equals("album", StringComparison.Ordinal)) return Constant(17, "setoa/album"); + + return Constant(0, "setoa/default"); + } + #endregion + + #region khan + private ResolvedPalette? ResolveKhan(DataArchiveEntry entry) + { + var name = BaseName(entry); + + if (name.Length < 2) + return null; + + var khanpal = Sibling("khanpal.dat"); + + if (khanpal is null) + return null; + + var isMale = name[0] == 'm'; + var letter = RemapKhanLetter(char.ToLowerInvariant(name[1])); + + switch (letter) + { + case 'm': + { + // bodies have no table; the palette numbers are the in-game body-colour values. Resolve to the + // lowest available palm number and report the rule so a host can offer the rest. + var map = IndexedMap(khanpal, "khanpal", "palm"); + + if ((map is null) || (map.Count == 0)) + return null; + + var lowest = map.Keys.Min(); + + return new ResolvedPalette(map[lowest], lowest, false, PaletteSourceKind.Indexed, "khan/body"); + } + case 'n': + // pants palettes are generated from legend.dat's color0.tbl dye ramp, not stored. Resolve to + // dye index 0 (indices 0-15 are the valid pants range). + return KhanPantsDye(); + case 'b' or 'c' or 'e' or 'f' or 'h' or 'i' or 'l' or 'p' or 'u' or 'w': + { + entry.TryGetNumericIdentifier(out var id, 3); // khan names carry more than one number + var overrideType = isMale ? KhanPalOverrideType.Male : KhanPalOverrideType.Female; + + return Table(khanpal, "khanpal", $"pal{letter}", $"pal{letter}", id, overrideType, "khan/letter"); + } + default: + return null; + } + } + + private ResolvedPalette? KhanPantsDye() + { + var legend = Sibling("legend.dat"); + + if ((legend is null) || !legend.TryGetValue("color0.tbl", out var colorEntry)) + return null; + + var colorTable = ColorTable.FromEntry(colorEntry); + + if (!colorTable.TryGetValue(0, out var dyeZero)) + return null; + + var palette = new Palette().Dye(dyeZero); + + return new ResolvedPalette(palette, 0, false, PaletteSourceKind.Indexed, "khan/pants"); + } + + private static char RemapKhanLetter(char letter) + => letter switch + { + 'a' => 'b', + 'g' or 'j' => 'c', + 'o' => 'm', + 's' => 'p', + _ => letter + }; + #endregion + + #region palette builders + private ResolvedPalette? Table( + DataArchive archive, + string archiveTag, + string tablePattern, + string palettePattern, + int id, + KhanPalOverrideType overrideType, + string ruleId) + { + var lookup = GetLookup(archive, archiveTag, tablePattern, palettePattern); + + if ((lookup is null) || (lookup.Palettes.Count == 0)) + return null; + + var rawNumber = lookup.Table.GetPaletteNumber(id, overrideType); + var luminanceBlended = rawNumber >= LUMINANCE_THRESHOLD; + var palette = lookup.GetPaletteForId(id, overrideType); + + return new ResolvedPalette( + palette, + luminanceBlended ? rawNumber - LUMINANCE_THRESHOLD : rawNumber, + luminanceBlended, + PaletteSourceKind.Table, + ruleId); + } + + private ResolvedPalette? Indexed(DataArchive archive, string archiveTag, string pattern, int id, string ruleId, bool forceFieldZero = false) + { + var map = IndexedMap(archive, archiveTag, pattern, forceFieldZero); + + if ((map is null) || !map.TryGetValue(id, out var palette)) + return null; + + return new ResolvedPalette(palette, id, false, PaletteSourceKind.Indexed, ruleId); + } + + private ResolvedPalette? Constant(int number, string ruleId) + { + var map = IndexedMap(Archive, "main", "gui"); + + if ((map is null) || !map.TryGetValue(number, out var palette)) + return null; + + return new ResolvedPalette(palette, number, false, PaletteSourceKind.Constant, ruleId); + } + + private ResolvedPalette? Fixed(DataArchive archive, string archiveTag, string entryName, string ruleId) + { + var palette = GetFixed(archive, archiveTag, entryName); + + return palette is null ? null : new ResolvedPalette(palette, NumberInName(entryName), false, PaletteSourceKind.Fixed, ruleId); + } + + private ResolvedPalette? SiblingLegendPal(string ruleId) + { + var legend = Sibling("legend.dat"); + + return legend is null ? null : Fixed(legend, "legend", "legend.pal", ruleId); + } + #endregion + + #region caches + private PaletteLookup? GetLookup(DataArchive archive, string archiveTag, string tablePattern, string palettePattern) + { + var key = $"{archiveTag}|{tablePattern}|{palettePattern}"; + + if (LookupCache.TryGetValue(key, out var cached)) + return cached; + + PaletteLookup? lookup; + + try + { + lookup = PaletteLookup.FromArchive(tablePattern, palettePattern, archive); + + if (lookup.Palettes.Count == 0) + lookup = null; + } catch + { + lookup = null; + } + + LookupCache[key] = lookup; + + return lookup; + } + + private Dictionary? IndexedMap(DataArchive archive, string archiveTag, string pattern, bool forceFieldZero = false) + { + var key = $"{archiveTag}|{pattern}"; + + if (IndexedCache.TryGetValue(key, out var cached)) + return cached; + + Dictionary? map; + + try + { + map = Palette.FromArchive(pattern, archive); + + if (map.Count == 0) + map = null; + + // wart: setoa's stray fielde00.pal also parses to id 0 and can win slot 0 over the real + // field000.pal. Force field000.pal into slot 0. The parser is correct; the archive is odd. + if ((map is not null) && forceFieldZero && archive.TryGetValue("field000.pal", out var field000)) + map[0] = Palette.FromEntry(field000); + } catch + { + map = null; + } + + IndexedCache[key] = map; + + return map; + } + + private Palette? GetFixed(DataArchive archive, string archiveTag, string entryName) + { + var key = $"{archiveTag}|{entryName}"; + + if (FixedCache.TryGetValue(key, out var cached)) + return cached; + + var palette = archive.TryGetValue(entryName, out var entry) ? Palette.FromEntry(entry) : null; + FixedCache[key] = palette; + + return palette; + } + + private DataArchive? Sibling(string fileName) + { + if (SiblingCache.TryGetValue(fileName, out var cached)) + return cached; + + var archive = Provider(fileName); + SiblingCache[fileName] = archive; + + return archive; + } + #endregion + + #region helpers + private const int ITEMS_PER_SHEET = 266; + private const int LUMINANCE_THRESHOLD = 1000; + + private static string BaseName(DataArchiveEntry entry) + => Path.GetFileNameWithoutExtension(entry.EntryName) + .ToLowerInvariant(); + + private static bool StartsWithAny(string name, params string[] prefixes) + { + foreach (var prefix in prefixes) + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return true; + + return false; + } + + private static string NormalizeArchiveName(string archiveName) + { + var name = archiveName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase) + ? archiveName[..^4] + : archiveName; + + return name.ToLowerInvariant(); + } + + private static int NumberInName(string entryName) + { + var digits = new string(Path.GetFileNameWithoutExtension(entryName) + .Where(char.IsDigit) + .ToArray()); + + return int.TryParse(digits, out var value) ? value : 0; + } + #endregion +} + +/// +/// Supplies a sibling archive by file name (e.g. khanpal.dat, legend.dat) to a +/// , or null when the host cannot provide it. +/// +public delegate DataArchive? ArchiveProvider(string fileName); + +/// +/// The palette a selected for an entry, plus how it was found. +/// +/// +/// The resolved palette. +/// +/// +/// The palette number, after any luminance (≥1000) subtraction. For fixed rules, the number parsed from +/// the palette file name (or 0). +/// +/// +/// True when the palette carries luminance alpha; a Skia consumer must render it with straight +/// (unpremultiplied) alpha. +/// +/// +/// Which kind of source the rule used. +/// +/// +/// Stable rule identifier (e.g. setoa/gbicon12, khan/letter, legend/bkstory). Lets a +/// UI report which rule fired and lets a test assert on the rule instead of comparing colors. +/// +public sealed record ResolvedPalette( + Palette Palette, + int PaletteNumber, + bool IsLuminanceBlended, + PaletteSourceKind Kind, + string RuleId);