From f34c25afb0f69dbdd547e4580068c1f3150407df Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 22 Jul 2026 17:04:13 +0200 Subject: [PATCH] custom palette taxonomy --- internal/topdata/native.go | 3 + internal/topdata/tlk_native.go | 73 ++++++++++++++++++++- internal/topdata/top_package.go | 34 +++++++++- internal/topdata/topdata_test.go | 107 +++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 6 deletions(-) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 8ec519d..7bbc276 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -410,6 +410,9 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress if err != nil { return BuildResult{}, err } + if err := loadStandaloneTLKStrings(sourceDir, compiler); err != nil { + return BuildResult{}, err + } output2DA := compiled2DAOutputDir(p) outputTLK := compiledTLKOutputDir(p) diff --git a/internal/topdata/tlk_native.go b/internal/topdata/tlk_native.go index a6daa67..8bf1a0e 100644 --- a/internal/topdata/tlk_native.go +++ b/internal/topdata/tlk_native.go @@ -13,6 +13,7 @@ import ( "strings" "golang.org/x/text/encoding/charmap" + "gopkg.in/yaml.v3" ) const ( @@ -74,6 +75,16 @@ type tlkEntryData struct { SoundLength float32 } +type standaloneTLKDocument struct { + Schema string `yaml:"schema"` + BaseStrref int `yaml:"base_strref"` + Strings []struct { + Key string `yaml:"key"` + Text string `yaml:"text"` + ID *int `yaml:"id"` + } `yaml:"strings"` +} + type tlkStateDocument struct { Version int `json:"version"` Language string `json:"language"` @@ -157,6 +168,42 @@ func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, erro return compiler, nil } +func loadStandaloneTLKStrings(sourceDir string, compiler *tlkCompiler) error { + path := filepath.Join(sourceDir, "tlk", "custom.tlk.yml") + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read %s: %w", path, err) + } + var document standaloneTLKDocument + if err := yaml.Unmarshal(raw, &document); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + if document.Schema != "sow-topdata/tlk/v1" { + return fmt.Errorf("%s: unsupported schema %q", path, document.Schema) + } + if document.BaseStrref != customTLKBase { + return fmt.Errorf("%s: base_strref must be %d", path, customTLKBase) + } + seen := map[string]struct{}{} + for index, entry := range document.Strings { + entry.Key = strings.TrimSpace(entry.Key) + if entry.Key == "" || entry.Text == "" || entry.ID == nil { + return fmt.Errorf("%s: strings[%d] requires key, text, and id", path, index) + } + if _, ok := seen[entry.Key]; ok { + return fmt.Errorf("%s: duplicate string key %q", path, entry.Key) + } + seen[entry.Key] = struct{}{} + if err := compiler.registerInlineAtID(entry.Key, *entry.ID, tlkEntryData{Text: entry.Text}); err != nil { + return err + } + } + return nil +} + func loadBaseDialogData(path string) (*legacyTLKData, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -329,6 +376,21 @@ func (c *tlkCompiler) registerInline(key string, entry tlkEntryData) (tlkCompile }, nil } +func (c *tlkCompiler) registerInlineAtID(key string, id int, entry tlkEntryData) error { + if id < 0 { + return fmt.Errorf("TLK key %q has negative id %d", key, id) + } + if mapping, ok := c.state.Entries[key]; ok && mapping.ID != id { + return fmt.Errorf("TLK key %q changed id from %d to %d", key, mapping.ID, id) + } + if owner, ok := c.reservedByID[id]; ok && owner != key { + return fmt.Errorf("TLK id %d is already reserved by %q", id, owner) + } + c.state.Entries[key] = tlkStateMapping{ID: id} + c.reservedByID[id] = key + return c.markActive(key, entry) +} + func (c *tlkCompiler) customStrrefForKey(key string) int { return customTLKBase + c.state.Entries[key].ID } @@ -338,6 +400,7 @@ func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error { if !ok { mapping = tlkStateMapping{ID: c.allocateID(), Retired: false} c.state.Entries[key] = mapping + c.reservedByID[mapping.ID] = key } existing, ok := c.active[key] if ok && existing != entry { @@ -351,9 +414,13 @@ func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error { } func (c *tlkCompiler) allocateID() int { - id := c.nextID - c.nextID++ - return id + for { + id := c.nextID + c.nextID++ + if _, reserved := c.reservedByID[id]; !reserved { + return id + } + } } func (c *tlkCompiler) finish(outputDir, tlkName string) (int, error) { diff --git a/internal/topdata/top_package.go b/internal/topdata/top_package.go index a860988..6736ea8 100644 --- a/internal/topdata/top_package.go +++ b/internal/topdata/top_package.go @@ -2,6 +2,7 @@ package topdata import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) @@ -215,10 +217,15 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er if err != nil { return err } - if skipTopPackageAsset(rel) { - return nil + var resource erf.Resource + if strings.HasSuffix(strings.ToLower(rel), ".itp.json") { + resource, err = topPackageITPResourceFromJSON(path) + } else { + if skipTopPackageAsset(rel) { + return nil + } + resource, err = topPackageResourceFromPath(path) } - resource, err := topPackageResourceFromPath(path) if err != nil { return err } @@ -251,6 +258,27 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er return resources, assetFiles, nil } +func topPackageITPResourceFromJSON(path string) (erf.Resource, error) { + raw, err := os.ReadFile(path) + if err != nil { + return erf.Resource{}, fmt.Errorf("read %s: %w", path, err) + } + var document gff.Document + if err := json.Unmarshal(raw, &document); err != nil { + return erf.Resource{}, fmt.Errorf("parse %s: %w", path, err) + } + if document.FileType != "ITP " { + return erf.Resource{}, fmt.Errorf("%s: file_type must be ITP", path) + } + var payload bytes.Buffer + if err := gff.Write(&payload, document); err != nil { + return erf.Resource{}, fmt.Errorf("compile %s: %w", path, err) + } + resourceType, _ := erf.HAKResourceTypeForExtension("itp") + name := strings.TrimSuffix(filepath.Base(path), ".itp.json") + return erf.Resource{Name: strings.ToLower(name), Type: resourceType, Data: payload.Bytes(), Size: int64(payload.Len())}, nil +} + func shouldSkipTopDataSourceDir(path string, skipDirs map[string]struct{}) bool { _, ok := skipDirs[filepath.Clean(path)] return ok diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index fdb00a4..61971ed 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -15,6 +15,7 @@ import ( "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) @@ -2396,6 +2397,58 @@ func TestBuildUsesNativeModeForInlineTLKAndWritesState(t *testing.T) { } } +func TestBuildNativeCompilesStandaloneTLKStrings(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "tlk")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION"], + "rows": [{ + "id": 0, + "key": "feat:test", + "LABEL": "TEST_LABEL", + "FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}}, + "DESCRIPTION": "****" + }] + }`+"\n") + writeFile(t, filepath.Join(root, "topdata", "tlk", "custom.tlk.yml"), `schema: sow-topdata/tlk/v1 +base_strref: 16777216 +strings: + - key: palette.creatures.npcs + text: NPCs + id: 50 +`) + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile)) + if err != nil { + t.Fatalf("read tlk state: %v", err) + } + var state tlkStateDocument + if err := json.Unmarshal(stateRaw, &state); err != nil { + t.Fatalf("parse tlk state: %v", err) + } + mapping, ok := state.Entries["palette.creatures.npcs"] + if !ok || mapping.Retired { + t.Fatalf("expected active standalone TLK mapping, got %#v", mapping) + } + if got := state.Entries["feat:test.name"].ID; got != 0 || mapping.ID != 50 { + t.Fatalf("standalone strings must keep pinned ids without shifting dataset refs, got dataset=%d standalone=%d", got, mapping.ID) + } + tlkRaw, err := os.ReadFile(filepath.Join(result.OutputTLKDir, defaultTLKName)) + if err != nil { + t.Fatalf("read compiled tlk: %v", err) + } + if !bytes.Contains(tlkRaw, []byte("NPCs")) { + t.Fatalf("compiled TLK does not contain standalone text") + } +} + func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) @@ -9354,6 +9407,60 @@ func TestBuildAndPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { } } +func TestBuildAndPackageCompilesITPJSONAssets(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) + mkdirAll(t, filepath.Join(root, "topdata", "assets", "palette")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ + "output": "repadjust.2da", + "columns": ["Label"], + "rows": [{"id": 0, "Label": "TEST_LABEL"}] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "assets", "palette", "creaturepal.itp.json"), `{ + "file_type": "ITP ", + "file_version": "V3.2", + "root": { + "struct_type": 4294967295, + "fields": [ + {"label": "MAIN", "type": "List", "value": []}, + {"label": "RESTYPE", "type": "Word", "value": 2027}, + {"label": "NEXT_USEABLE_ID", "type": "Byte", "value": 51} + ] + } +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildAndPackage(proj, nil) + if err != nil { + t.Fatalf("BuildAndPackage failed: %v", err) + } + input, err := os.Open(result.OutputHAKPath) + if err != nil { + t.Fatalf("open hak: %v", err) + } + defer input.Close() + archive, err := erf.Read(input) + if err != nil { + t.Fatalf("read hak: %v", err) + } + for _, resource := range archive.Resources { + if resource.Name != "creaturepal" || resource.Type != 0x07EE { + continue + } + document, err := gff.Read(bytes.NewReader(resource.Data)) + if err != nil { + t.Fatalf("read compiled ITP: %v", err) + } + if document.FileType != "ITP " || document.FileVersion != "V3.2" { + t.Fatalf("unexpected compiled ITP header: %#v", document) + } + return + } + t.Fatalf("expected creaturepal.itp in top package") +} + func TestCollectTopPackageResourcesRejectsDuplicateTopAssetKeys(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, ".cache", "2da"))