Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e7cead5c0 | ||
|
|
4d38967078 |
@@ -126,8 +126,9 @@ exactly as the reference does:
|
||||
- Beamdog install dirs.
|
||||
- `--nwn <install>` overrides (path to the install root or directly to the
|
||||
binary).
|
||||
- **Headless.** The engine is a GUI binary. If there is no `DISPLAY` and
|
||||
`xvfb-run` is present, wrap the call:
|
||||
- **Headless.** The engine is a GUI binary. Require `xvfb-run` and 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>`.
|
||||
- **Per model (one at a time — the engine's `development/` and `modelcompiler/`
|
||||
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)
|
||||
|
||||
// Headless wrap: no DISPLAY + xvfb-run present -> run under a virtual X.
|
||||
var wrap []string
|
||||
if getenv("DISPLAY") == "" {
|
||||
if xvfb := look("xvfb-run"); xvfb != "" {
|
||||
wrap = []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
||||
}
|
||||
// Always use a virtual X so compilation never opens the client UI.
|
||||
xvfb := look("xvfb-run")
|
||||
if xvfb == "" {
|
||||
fmt.Fprintln(stderr, "assets compile: xvfb-run not found — install it to run the NWN model compiler headlessly")
|
||||
return exitTool
|
||||
}
|
||||
wrap := []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
||||
|
||||
files, err := walk(dirs, mdlExt, !*nonRecursive)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -23,13 +24,19 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
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 {
|
||||
switch k {
|
||||
case "HOME":
|
||||
return home
|
||||
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 ""
|
||||
}
|
||||
@@ -38,7 +45,11 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
||||
orig := runner
|
||||
defer func() { runner = orig }()
|
||||
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]
|
||||
compiled := filepath.Join(mc, stem+".mdl")
|
||||
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 {
|
||||
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 {
|
||||
if k == "HOME" {
|
||||
return home
|
||||
@@ -108,3 +124,48 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ type tlkCompiler struct {
|
||||
active map[string]tlkEntryData
|
||||
activeKeys map[string]struct{}
|
||||
reservedByID map[int]string
|
||||
pinnedByID map[int]string
|
||||
nextID int
|
||||
}
|
||||
|
||||
@@ -158,6 +159,7 @@ func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, erro
|
||||
active: map[string]tlkEntryData{},
|
||||
activeKeys: map[string]struct{}{},
|
||||
reservedByID: reserved,
|
||||
pinnedByID: map[int]string{},
|
||||
nextID: nextID,
|
||||
}
|
||||
if legacy != nil {
|
||||
@@ -380,12 +382,24 @@ func (c *tlkCompiler) registerInlineAtID(key string, id int, entry tlkEntryData)
|
||||
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 {
|
||||
return fmt.Errorf("TLK key %q changed id from %d to %d", key, 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 {
|
||||
return fmt.Errorf("TLK id %d is already reserved by %q", id, owner)
|
||||
// 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)
|
||||
|
||||
@@ -2449,6 +2449,55 @@ strings:
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
root := testProjectRoot(t)
|
||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
||||
|
||||
Reference in New Issue
Block a user