diff --git a/docs/superpowers/specs/2026-07-12-crucible-assets-builder-design.md b/docs/superpowers/specs/2026-07-12-crucible-assets-builder-design.md index 831f754..b6c6652 100644 --- a/docs/superpowers/specs/2026-07-12-crucible-assets-builder-design.md +++ b/docs/superpowers/specs/2026-07-12-crucible-assets-builder-design.md @@ -126,8 +126,9 @@ exactly as the reference does: - Beamdog install dirs. - `--nwn ` 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 `. - **Per model (one at a time — the engine's `development/` and `modelcompiler/` folders are flat and single-slot):** diff --git a/internal/assets/compile.go b/internal/assets/compile.go index dca73a2..6802691 100644 --- a/internal/assets/compile.go +++ b/internal/assets/compile.go @@ -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 { diff --git a/internal/assets/compile_test.go b/internal/assets/compile_test.go index 5af0ce6..0aa0fa3 100644 --- a/internal/assets/compile_test.go +++ b/internal/assets/compile_test.go @@ -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 + 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()) + } +}