Compare commits

...
3 Commits
Author SHA1 Message Date
archvillainette 8e7cead5c0 Keep model compilation headless (#49)
build-binaries / build-binaries (push) Successful in 2m14s
## Summary

- always wrap NWN model compilation with `xvfb-run`, even when the caller has `DISPLAY`
- fail closed with an actionable error when `xvfb-run` is unavailable instead of opening the client UI
- update the compiler contract and regression coverage

## Verification

- focused red/green regression tests
- `nix develop -c make check`
- real NWN compile with `DISPLAY=:0` under transient Xvfb; binary MDL output verified

Generated with Claude CodeReviewed-on: #49

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-23 07:27:37 +00:00
archvillainette 4d38967078 fix(topdata): let pinned TLK ids evict stale state owners (#48)
build-binaries / build-binaries (push) Successful in 2m14s
## Problem

The custom palette taxonomy (#46) pins display strings to fixed TLK ids in `tlk/custom.tlk.yml`, and `registerInlineAtID` demanded each pinned id be free. But `.tlk_state.json` is **gitignored and per-machine** — each dev grows their own copy, and before #46 every ref got its id dynamically (first-come-first-served).

On any machine whose state predates #46, a ref could have already parked on a now-pinned id. That fails the build:

```
topdata validation failed with 1 error(s): error: native topdata buildability check failed:
TLK id 2689 is already reserved by "feat:yuanti/alternate_form.feat"
```

It only passes on machines whose state was regenerated after #46 (pinned window already clean). The `allocateID` reserved-skip that #46 added protects a *fresh* build, but does nothing about a *stale cache*.

## Fix

Make the pin authoritative over the per-machine cache. A stale cached owner sitting on a pinned id is evicted and reallocated a fresh id when next made active; only two pins fighting over the same id in `custom.tlk.yml` is now an error. This self-heals on the next build — no manual `.tlk_state.json` deletion needed.

Caveat: the evicted ref's strref shifts on affected machines. That's unavoidable (something must move off the pinned id), and dynamic strrefs were never stable across machines anyway.

## Tests

Added `TestBuildStandaloneTLKPinEvictsStaleStateOwner` (seeds a pre-#46 state with a feat parked on the pinned id, asserts the build succeeds, the pin owns it, and the feat is reallocated). Full `internal/topdata` suite + `go vet` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #48
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-22 18:17:04 +00:00
archvillainette 7b481ca0c0 custom palette taxonomy (#46)
build-binaries / build-binaries (push) Successful in 2m12s
Implements custom palette taxonomyReviewed-on: #46

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-22 17:03:58 +00:00
7 changed files with 346 additions and 16 deletions
@@ -126,8 +126,9 @@ exactly as the reference does:
- Beamdog install dirs. - Beamdog install dirs.
- `--nwn <install>` overrides (path to the install root or directly to the - `--nwn <install>` overrides (path to the install root or directly to the
binary). binary).
- **Headless.** The engine is a GUI binary. If there is no `DISPLAY` and - **Headless.** The engine is a GUI binary. Require `xvfb-run` and wrap the call
`xvfb-run` is present, wrap the call: regardless of the caller's `DISPLAY` so compilation never opens the client
UI; fail before invoking the engine when `xvfb-run` is unavailable:
`xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`. `xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`.
- **Per model (one at a time — the engine's `development/` and `modelcompiler/` - **Per model (one at a time — the engine's `development/` and `modelcompiler/`
folders are flat and single-slot):** folders are flat and single-slot):**
+6 -6
View File
@@ -40,13 +40,13 @@ func runCompile(args []string, stdout, stderr io.Writer, getenv func(string) str
} }
binDir := filepath.Dir(nwmain) binDir := filepath.Dir(nwmain)
// Headless wrap: no DISPLAY + xvfb-run present -> run under a virtual X. // Always use a virtual X so compilation never opens the client UI.
var wrap []string xvfb := look("xvfb-run")
if getenv("DISPLAY") == "" { if xvfb == "" {
if xvfb := look("xvfb-run"); xvfb != "" { fmt.Fprintln(stderr, "assets compile: xvfb-run not found — install it to run the NWN model compiler headlessly")
wrap = []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"} return exitTool
}
} }
wrap := []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
files, err := walk(dirs, mdlExt, !*nonRecursive) files, err := walk(dirs, mdlExt, !*nonRecursive)
if err != nil { if err != nil {
+63 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -23,13 +24,19 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil { if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
xvfbDir := t.TempDir()
xvfb := filepath.Join(xvfbDir, "xvfb-run")
if err := os.WriteFile(xvfb, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
getenv := func(k string) string { getenv := func(k string) string {
switch k { switch k {
case "HOME": case "HOME":
return home return home
case "DISPLAY": case "DISPLAY":
return ":0" // pretend a display exists so no xvfb wrap is needed return ":0" // a desktop display must not make compilation interactive
} }
return "" return ""
} }
@@ -38,7 +45,11 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
orig := runner orig := runner
defer func() { runner = orig }() defer func() { runner = orig }()
runner = func(dir string, env []string, name string, args ...string) ([]byte, error) { runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
// args: compilemodel <stem> if name != xvfb || len(args) != 5 || args[0] != "-a" ||
args[1] != "--server-args=-screen 0 1024x768x24" || args[2] != nwmain ||
args[3] != "compilemodel" {
t.Fatalf("engine command = %q %q, want xvfb-run wrapping nwmain", name, args)
}
stem := args[len(args)-1] stem := args[len(args)-1]
compiled := filepath.Join(mc, stem+".mdl") compiled := filepath.Join(mc, stem+".mdl")
return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644) return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
@@ -77,6 +88,11 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil { if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
xvfbDir := t.TempDir()
if err := os.WriteFile(filepath.Join(xvfbDir, "xvfb-run"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
getenv := func(k string) string { getenv := func(k string) string {
if k == "HOME" { if k == "HOME" {
return home return home
@@ -108,3 +124,48 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
t.Fatal("engine should not run for a name-mismatched model") t.Fatal("engine should not run for a name-mismatched model")
} }
} }
func TestCompileFailsClosedWithoutXvfb(t *testing.T) {
home := t.TempDir()
userData := filepath.Join(home, ".local", "share", "Neverwinter Nights")
for _, d := range []string{filepath.Join(userData, "development"), filepath.Join(userData, "modelcompiler")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
nwmain := filepath.Join(t.TempDir(), "nwmain-linux")
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", t.TempDir())
getenv := func(k string) string {
if k == "HOME" {
return home
}
return ""
}
orig := runner
defer func() { runner = orig }()
engineCalled := false
runner = func(string, []string, string, ...string) ([]byte, error) {
engineCalled = true
return nil, nil
}
srcDir := t.TempDir()
if err := os.WriteFile(filepath.Join(srcDir, "foo.mdl"),
[]byte("newmodel foo\nbeginmodelgeom foo\n node dummy foo\n parent null\n endnode\nendmodelgeom foo\ndonemodel foo\n"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
if code := runCompile([]string{"--nwn", nwmain, srcDir}, &stdout, &stderr, getenv); code != exitTool {
t.Fatalf("compile exit = %d, want %d\n%s", code, exitTool, stderr.String())
}
if engineCalled {
t.Fatal("engine must not run without xvfb-run")
}
if !strings.Contains(stderr.String(), "xvfb-run") {
t.Fatalf("missing actionable xvfb-run error: %s", stderr.String())
}
}
+3
View File
@@ -410,6 +410,9 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
if err := loadStandaloneTLKStrings(sourceDir, compiler); err != nil {
return BuildResult{}, err
}
output2DA := compiled2DAOutputDir(p) output2DA := compiled2DAOutputDir(p)
outputTLK := compiledTLKOutputDir(p) outputTLK := compiledTLKOutputDir(p)
+81
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/charmap"
"gopkg.in/yaml.v3"
) )
const ( const (
@@ -74,6 +75,16 @@ type tlkEntryData struct {
SoundLength float32 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 { type tlkStateDocument struct {
Version int `json:"version"` Version int `json:"version"`
Language string `json:"language"` Language string `json:"language"`
@@ -98,6 +109,7 @@ type tlkCompiler struct {
active map[string]tlkEntryData active map[string]tlkEntryData
activeKeys map[string]struct{} activeKeys map[string]struct{}
reservedByID map[int]string reservedByID map[int]string
pinnedByID map[int]string
nextID int nextID int
} }
@@ -147,6 +159,7 @@ func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, erro
active: map[string]tlkEntryData{}, active: map[string]tlkEntryData{},
activeKeys: map[string]struct{}{}, activeKeys: map[string]struct{}{},
reservedByID: reserved, reservedByID: reserved,
pinnedByID: map[int]string{},
nextID: nextID, nextID: nextID,
} }
if legacy != nil { if legacy != nil {
@@ -157,6 +170,42 @@ func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, erro
return compiler, nil 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) { func loadBaseDialogData(path string) (*legacyTLKData, error) {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -329,6 +378,33 @@ func (c *tlkCompiler) registerInline(key string, entry tlkEntryData) (tlkCompile
}, nil }, 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)
}
// The pin is authoritative over the per-machine .tlk_state.json cache: a
// stale mapping that dynamically grabbed this id on an older build must
// yield so the pinned key can take it. Only a genuine clash between two
// pins in custom.tlk.yml is an author error.
if owner, ok := c.pinnedByID[id]; ok && owner != key {
return fmt.Errorf("TLK id %d is pinned by both %q and %q", id, owner, key)
}
if mapping, ok := c.state.Entries[key]; ok && mapping.ID != id {
// This key held a different cached id; release it so the pin wins.
if c.reservedByID[mapping.ID] == key {
delete(c.reservedByID, mapping.ID)
}
}
if owner, ok := c.reservedByID[id]; ok && owner != key {
// Evict the stale owner; it gets a fresh id when next made active.
delete(c.state.Entries, owner)
}
c.pinnedByID[id] = key
c.state.Entries[key] = tlkStateMapping{ID: id}
c.reservedByID[id] = key
return c.markActive(key, entry)
}
func (c *tlkCompiler) customStrrefForKey(key string) int { func (c *tlkCompiler) customStrrefForKey(key string) int {
return customTLKBase + c.state.Entries[key].ID return customTLKBase + c.state.Entries[key].ID
} }
@@ -338,6 +414,7 @@ func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error {
if !ok { if !ok {
mapping = tlkStateMapping{ID: c.allocateID(), Retired: false} mapping = tlkStateMapping{ID: c.allocateID(), Retired: false}
c.state.Entries[key] = mapping c.state.Entries[key] = mapping
c.reservedByID[mapping.ID] = key
} }
existing, ok := c.active[key] existing, ok := c.active[key]
if ok && existing != entry { if ok && existing != entry {
@@ -351,10 +428,14 @@ func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error {
} }
func (c *tlkCompiler) allocateID() int { func (c *tlkCompiler) allocateID() int {
for {
id := c.nextID id := c.nextID
c.nextID++ c.nextID++
if _, reserved := c.reservedByID[id]; !reserved {
return id return id
} }
}
}
func (c *tlkCompiler) finish(outputDir, tlkName string) (int, error) { func (c *tlkCompiler) finish(outputDir, tlkName string) (int, error) {
if err := os.MkdirAll(outputDir, 0o755); err != nil { if err := os.MkdirAll(outputDir, 0o755); err != nil {
+29 -1
View File
@@ -2,6 +2,7 @@ package topdata
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -13,6 +14,7 @@ import (
"time" "time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
) )
@@ -215,10 +217,15 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
if err != nil { if err != nil {
return err return err
} }
var resource erf.Resource
if strings.HasSuffix(strings.ToLower(rel), ".itp.json") {
resource, err = topPackageITPResourceFromJSON(path)
} else {
if skipTopPackageAsset(rel) { if skipTopPackageAsset(rel) {
return nil return nil
} }
resource, err := topPackageResourceFromPath(path) resource, err = topPackageResourceFromPath(path)
}
if err != nil { if err != nil {
return err return err
} }
@@ -251,6 +258,27 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
return resources, assetFiles, nil 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 { func shouldSkipTopDataSourceDir(path string, skipDirs map[string]struct{}) bool {
_, ok := skipDirs[filepath.Clean(path)] _, ok := skipDirs[filepath.Clean(path)]
return ok return ok
+156
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
) )
@@ -2396,6 +2397,107 @@ 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 TestBuildStandaloneTLKPinEvictsStaleStateOwner(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: sow.module.name
text: Shadows Over Westgate
id: 50
`)
// Simulate a per-machine state that predates the pinned taxonomy: the feat
// ref dynamically grabbed id 50 on an older build, exactly where the pin
// now lives. The build must self-heal instead of failing.
writeFile(t, filepath.Join(root, "topdata", tlkStateFile),
`{"version":1,"language":"en","entries":{"feat:test.name":{"id":50}}}`+"\n")
if _, err := BuildNative(testProject(root), nil); err != nil {
t.Fatalf("BuildNative failed on stale pin collision: %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)
}
if state.Entries["sow.module.name"].ID != 50 {
t.Fatalf("pin must own id 50, got %#v", state.Entries["sow.module.name"])
}
if got := state.Entries["feat:test.name"].ID; got == 50 {
t.Fatalf("stale feat ref should have been reallocated off id 50, got %d", got)
}
}
func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) { func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
@@ -9354,6 +9456,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) { func TestCollectTopPackageResourcesRejectsDuplicateTopAssetKeys(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, ".cache", "2da")) mkdirAll(t, filepath.Join(root, ".cache", "2da"))