Generate palette projections at module build; never extract them
ci / ci (pull_request) Successful in 3m44s

*palcus.itp files are Toolset-generated snapshots: category skeleton plus
one descriptor per custom blueprint. Treat them as derived data. Module
source now commits only descriptor-free skeletons; build strips any stale
descriptors and re-inserts descriptors computed from the blueprints in the
source tree (name/STRREF, resref, and CR/faction for creatures), so the
DM Creator lists always match what actually ships in the .mod. Compare
applies the same projection. Extract skips *palcus.itp entirely, so local
Toolset palette churn can no longer leak into module source.

Blueprints with PaletteID 255 (hidden) or no PaletteID are omitted, per
engine convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:24:49 +02:00
co-authored by Claude Fable 5
parent 8e7cead5c0
commit 450c3ebc59
5 changed files with 513 additions and 2 deletions
+191
View File
@@ -0,0 +1,191 @@
package pipeline
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const paletteTestSkeleton = `{
"file_type": "ITP ",
"file_version": "V3.2",
"root": {
"struct_type": 4294967295,
"fields": [
{
"label": "MAIN",
"type": "List",
"value": [
{
"struct_type": 0,
"fields": [
{"label": "STRREF", "type": "DWord", "value": 500},
{
"label": "LIST",
"type": "List",
"value": [
{
"struct_type": 0,
"fields": [
{"label": "STRREF", "type": "DWord", "value": 6699},
{"label": "ID", "type": "Byte", "value": 23},
{
"label": "LIST",
"type": "List",
"value": [
{
"struct_type": 0,
"fields": [
{"label": "NAME", "type": "CExoString", "value": "Stale Junk"},
{"label": "RESREF", "type": "ResRef", "value": "stalejunk"}
]
}
]
}
]
},
{
"struct_type": 0,
"fields": [
{"label": "STRREF", "type": "DWord", "value": 6753},
{"label": "ID", "type": "Byte", "value": 24}
]
}
]
}
]
}
]
}
]
}
}
`
func paletteTestItem(resref, name string, paletteID int) string {
return `{
"file_type": "UTI ",
"file_version": "V3.2",
"root": {
"struct_type": 4294967295,
"fields": [
{"label": "TemplateResRef", "type": "ResRef", "value": "` + resref + `"},
{
"label": "LocalizedName",
"type": "CExoLocString",
"value": {"string_ref": 4294967295, "entries": [{"id": 0, "value": "` + name + `"}]}
},
{"label": "PaletteID", "type": "Byte", "value": ` + itoa(paletteID) + `}
]
}
}
`
}
func itoa(v int) string {
data, _ := json.Marshal(v)
return string(data)
}
func TestBuildProjectsPaletteDescriptorsAndExtractSkipsThem(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module"))
mustMkdir(t, filepath.Join(root, "src", "palettes"))
mustMkdir(t, filepath.Join(root, "src", "blueprints", "items"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test Module", "resref": "testmod"},
"paths": {"source": "src", "build": "build"}
}
`)
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {"struct_type": 4294967295, "fields": [{"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}]}
}
`)
mustWriteFile(t, filepath.Join(root, "src", "palettes", "itempalcus.itp.json"), paletteTestSkeleton)
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_b.uti.json"), paletteTestItem("i_b", "Bravo Item", 23))
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_a.uti.json"), paletteTestItem("i_a", "Alpha Item", 23))
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_hidden.uti.json"), paletteTestItem("i_hidden", "Hidden Item", 255))
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
if _, err := BuildModule(p); err != nil {
t.Fatalf("build: %v", err)
}
archiveFile, err := os.Open(p.ModuleArchivePath())
if err != nil {
t.Fatalf("open module archive: %v", err)
}
defer archiveFile.Close()
archive, err := erf.Read(archiveFile)
if err != nil {
t.Fatalf("read module archive: %v", err)
}
var palette *gff.Document
for _, resource := range archive.Resources {
if resource.Name == "itempalcus" {
document, err := gff.Read(bytes.NewReader(resource.Data))
if err != nil {
t.Fatalf("decode itempalcus: %v", err)
}
palette = &document
}
}
if palette == nil {
t.Fatal("itempalcus missing from built module")
}
canonical, err := json.Marshal(palette)
if err != nil {
t.Fatalf("marshal palette: %v", err)
}
text := string(canonical)
if bytes.Contains(canonical, []byte("stalejunk")) {
t.Fatalf("stale descriptor survived projection: %s", text)
}
for _, resref := range []string{"i_a", "i_b"} {
if !bytes.Contains(canonical, []byte(resref)) {
t.Fatalf("descriptor %s missing from projection: %s", resref, text)
}
}
if bytes.Contains(canonical, []byte("i_hidden")) {
t.Fatalf("PaletteID 255 blueprint leaked into projection: %s", text)
}
if a, b := bytes.Index(canonical, []byte("i_a")), bytes.Index(canonical, []byte("i_b")); a > b {
t.Fatalf("descriptors not name-sorted: %s", text)
}
if _, err := Compare(p); err != nil {
t.Fatalf("compare after build: %v", err)
}
// Extraction must never write palcus files back into source.
if err := os.Remove(filepath.Join(root, "src", "palettes", "itempalcus.itp.json")); err != nil {
t.Fatalf("remove skeleton: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("rescan: %v", err)
}
if _, err := Extract(p); err != nil {
t.Fatalf("extract: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "src", "palettes", "itempalcus.itp.json")); !os.IsNotExist(err) {
t.Fatalf("extract wrote palcus file back (stat err: %v)", err)
}
}