custom palette taxonomy
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,10 +414,14 @@ func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error {
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) allocateID() int {
|
||||
for {
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
if _, reserved := c.reservedByID[id]; !reserved {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) finish(outputDir, tlkName string) (int, error) {
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user