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
5 changed files with 137 additions and 12 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())
}
}
+16 -2
View File
@@ -109,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
} }
@@ -158,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 {
@@ -380,12 +382,24 @@ func (c *tlkCompiler) registerInlineAtID(key string, id int, entry tlkEntryData)
if id < 0 { if id < 0 {
return fmt.Errorf("TLK key %q has negative id %d", key, id) 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 { 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 { 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.state.Entries[key] = tlkStateMapping{ID: id}
c.reservedByID[id] = key c.reservedByID[id] = key
return c.markActive(key, entry) return c.markActive(key, entry)
+49
View File
@@ -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) { 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"))