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>
299 lines
8.2 KiB
Go
299 lines
8.2 KiB
Go
package pipeline
|
|
|
|
// Custom palette projections (*palcus.itp) are generated artifacts: the Toolset
|
|
// derives them from the category frameworks plus the blueprints present in the
|
|
// module. Crucible reproduces that projection deterministically at build time so
|
|
// the module source only carries descriptor-free category skeletons and the
|
|
// blueprint files themselves. Extract never writes palcus files back.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
const noStrref = 0xFFFFFFFF
|
|
|
|
// hiddenPaletteID suppresses a blueprint from the Custom palette (engine
|
|
// convention; see docs in sow-module's palette research notes).
|
|
const hiddenPaletteID = 255
|
|
|
|
var paletteFamilyByExtension = map[string]string{
|
|
".utc": "creaturepalcus",
|
|
".utd": "doorpalcus",
|
|
".ute": "encounterpalcus",
|
|
".uti": "itempalcus",
|
|
".utm": "storepalcus",
|
|
".utp": "placeablepalcus",
|
|
".uts": "soundpalcus",
|
|
".utt": "triggerpalcus",
|
|
".utw": "waypointpalcus",
|
|
}
|
|
|
|
type paletteDescriptor struct {
|
|
name string
|
|
strref uint32
|
|
resref string
|
|
creature bool
|
|
cr float32
|
|
faction string
|
|
}
|
|
|
|
func (d paletteDescriptor) sortKey() string {
|
|
if d.strref != noStrref || d.name == "" {
|
|
return strings.ToLower(d.resref)
|
|
}
|
|
return strings.ToLower(d.name)
|
|
}
|
|
|
|
// palette resref (e.g. "itempalcus") -> terminal category ID -> descriptors.
|
|
type paletteProjection map[string]map[uint8][]paletteDescriptor
|
|
|
|
func isPaletteProjectionResref(name string) bool {
|
|
return strings.HasSuffix(strings.ToLower(name), "palcus")
|
|
}
|
|
|
|
func collectPaletteDescriptors(p *project.Project) (paletteProjection, error) {
|
|
projection := paletteProjection{}
|
|
factions, err := loadFactionNames(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rel := range p.Inventory.SourceFiles {
|
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
name, extension, err := splitSourceName(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
family, ok := paletteFamilyByExtension[extension]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
}
|
|
var document gff.Document
|
|
if err := json.Unmarshal(raw, &document); err != nil {
|
|
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
|
|
}
|
|
|
|
descriptor, paletteID, ok := blueprintDescriptor(document.Root, name, extension, factions)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if projection[family] == nil {
|
|
projection[family] = map[uint8][]paletteDescriptor{}
|
|
}
|
|
projection[family][paletteID] = append(projection[family][paletteID], descriptor)
|
|
}
|
|
|
|
for _, byID := range projection {
|
|
for _, descriptors := range byID {
|
|
sort.SliceStable(descriptors, func(i, j int) bool {
|
|
a, b := descriptors[i], descriptors[j]
|
|
if a.sortKey() != b.sortKey() {
|
|
return a.sortKey() < b.sortKey()
|
|
}
|
|
return a.resref < b.resref
|
|
})
|
|
}
|
|
}
|
|
return projection, nil
|
|
}
|
|
|
|
func blueprintDescriptor(root gff.Struct, fileName, extension string, factions []string) (paletteDescriptor, uint8, bool) {
|
|
descriptor := paletteDescriptor{
|
|
strref: noStrref,
|
|
resref: strings.ToLower(fileName),
|
|
creature: extension == ".utc",
|
|
}
|
|
paletteID := -1
|
|
|
|
for _, field := range root.Fields {
|
|
switch field.Label {
|
|
case "PaletteID":
|
|
if v, ok := field.Value.(gff.ByteValue); ok {
|
|
paletteID = int(v)
|
|
}
|
|
case "TemplateResRef":
|
|
if v, ok := field.Value.(gff.ResRefValue); ok && v != "" {
|
|
descriptor.resref = strings.ToLower(string(v))
|
|
}
|
|
case "LocalizedName", "LocName", "FirstName":
|
|
if v, ok := field.Value.(gff.LocString); ok {
|
|
name, strref := locStringLabel(v)
|
|
if field.Label == "FirstName" {
|
|
descriptor.name = strings.TrimSpace(descriptor.name + " " + name)
|
|
if descriptor.strref == noStrref {
|
|
descriptor.strref = strref
|
|
}
|
|
} else {
|
|
descriptor.name = name
|
|
descriptor.strref = strref
|
|
}
|
|
}
|
|
case "LastName":
|
|
if v, ok := field.Value.(gff.LocString); ok {
|
|
name, _ := locStringLabel(v)
|
|
descriptor.name = strings.TrimSpace(descriptor.name + " " + name)
|
|
}
|
|
case "ChallengeRating":
|
|
if v, ok := field.Value.(gff.FloatValue); ok {
|
|
descriptor.cr = float32(v)
|
|
}
|
|
case "FactionID":
|
|
if v, ok := field.Value.(gff.WordValue); ok && int(v) < len(factions) {
|
|
descriptor.faction = factions[v]
|
|
}
|
|
}
|
|
}
|
|
|
|
if paletteID < 0 || paletteID == hiddenPaletteID {
|
|
return paletteDescriptor{}, 0, false
|
|
}
|
|
return descriptor, uint8(paletteID), true
|
|
}
|
|
|
|
func locStringLabel(value gff.LocString) (string, uint32) {
|
|
if value.StringRef != noStrref {
|
|
return "", value.StringRef
|
|
}
|
|
for _, entry := range value.Entries {
|
|
if entry.Value != "" {
|
|
return entry.Value, noStrref
|
|
}
|
|
}
|
|
return "", noStrref
|
|
}
|
|
|
|
func loadFactionNames(p *project.Project) ([]string, error) {
|
|
for _, rel := range p.Inventory.SourceFiles {
|
|
if !strings.HasSuffix(strings.ToLower(rel), "repute.fac.json") {
|
|
continue
|
|
}
|
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
}
|
|
var document gff.Document
|
|
if err := json.Unmarshal(raw, &document); err != nil {
|
|
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
|
|
}
|
|
var names []string
|
|
for _, field := range document.Root.Fields {
|
|
if field.Label != "FactionList" {
|
|
continue
|
|
}
|
|
list, ok := field.Value.(gff.ListValue)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, faction := range list {
|
|
name := ""
|
|
for _, f := range faction.Fields {
|
|
if f.Label == "FactionName" {
|
|
if v, ok := f.Value.(gff.StringValue); ok {
|
|
name = string(v)
|
|
}
|
|
}
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// projectPaletteDocument strips every blueprint descriptor from the palette
|
|
// tree and re-inserts the descriptors derived from module source. Category
|
|
// structure (branches, terminal IDs, labels) passes through untouched.
|
|
func projectPaletteDocument(document *gff.Document, byID map[uint8][]paletteDescriptor) {
|
|
for i, field := range document.Root.Fields {
|
|
if field.Label != "MAIN" {
|
|
continue
|
|
}
|
|
if list, ok := field.Value.(gff.ListValue); ok {
|
|
document.Root.Fields[i] = gff.NewField("MAIN", projectPaletteNodes(list, byID))
|
|
}
|
|
}
|
|
}
|
|
|
|
func projectPaletteNodes(nodes gff.ListValue, byID map[uint8][]paletteDescriptor) gff.ListValue {
|
|
out := make(gff.ListValue, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
if structHasField(node, "RESREF") {
|
|
continue // blueprint descriptor — regenerated below
|
|
}
|
|
|
|
terminalID := -1
|
|
fields := make([]gff.Field, 0, len(node.Fields))
|
|
for _, field := range node.Fields {
|
|
if field.Label == "LIST" {
|
|
continue // rebuilt for terminals, recursed for branches
|
|
}
|
|
if field.Label == "ID" {
|
|
if v, ok := field.Value.(gff.ByteValue); ok {
|
|
terminalID = int(v)
|
|
}
|
|
}
|
|
fields = append(fields, field)
|
|
}
|
|
|
|
switch {
|
|
case terminalID >= 0:
|
|
if descriptors := byID[uint8(terminalID)]; len(descriptors) > 0 {
|
|
fields = append(fields, gff.NewField("LIST", descriptorList(descriptors)))
|
|
}
|
|
default:
|
|
for _, field := range node.Fields {
|
|
if field.Label == "LIST" {
|
|
if list, ok := field.Value.(gff.ListValue); ok {
|
|
fields = append(fields, gff.NewField("LIST", projectPaletteNodes(list, byID)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out = append(out, gff.Struct{Type: node.Type, Fields: fields})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func descriptorList(descriptors []paletteDescriptor) gff.ListValue {
|
|
list := make(gff.ListValue, 0, len(descriptors))
|
|
for _, d := range descriptors {
|
|
fields := make([]gff.Field, 0, 4)
|
|
if d.strref != noStrref {
|
|
fields = append(fields, gff.NewField("STRREF", gff.DWordValue(d.strref)))
|
|
} else {
|
|
fields = append(fields, gff.NewField("NAME", gff.StringValue(d.name)))
|
|
}
|
|
fields = append(fields, gff.NewField("RESREF", gff.ResRefValue(d.resref)))
|
|
if d.creature {
|
|
fields = append(fields, gff.NewField("CR", gff.FloatValue(d.cr)))
|
|
fields = append(fields, gff.NewField("FACTION", gff.StringValue(d.faction)))
|
|
}
|
|
list = append(list, gff.Struct{Fields: fields})
|
|
}
|
|
return list
|
|
}
|
|
|
|
func structHasField(s gff.Struct, label string) bool {
|
|
for _, field := range s.Fields {
|
|
if field.Label == label {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|