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 }