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);