Compare commits
3
Commits
ff9424c8c3
...
v0.3.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e7cead5c0 | ||
|
|
4d38967078 | ||
|
|
7b481ca0c0 |
@@ -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):**
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user