package assets import ( "flag" "fmt" "io" "os" "path/filepath" "strings" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets/mdl" ) // runCompile compiles ASCII .mdl models to binary in place, one at a time (the // engine's development/ and modelcompiler/ folders are flat, single-slot). func runCompile(args []string, stdout, stderr io.Writer, getenv func(string) string) int { fs := flag.NewFlagSet("compile", flag.ContinueOnError) fs.SetOutput(stderr) nwn := fs.String("nwn", "", "path to the NWN install root or nwmain-linux binary") nonRecursive := fs.Bool("non-recursive", false, "do not descend into subdirectories") if err := fs.Parse(args); err != nil { return exitUsage } dirs := fs.Args() if len(dirs) == 0 { fmt.Fprintln(stderr, "assets compile: usage: compile [--nwn INSTALL] ...") return exitUsage } home := getenv("HOME") userData := filepath.Join(home, ".local", "share", "Neverwinter Nights") dev := filepath.Join(userData, "development") mc := filepath.Join(userData, "modelcompiler") nwmain := findNWMain(*nwn, home) if nwmain == "" { fmt.Fprintln(stderr, "assets compile: nwmain-linux not found — pass --nwn "+ "(Steam/GOG/Beamdog install root or the nwmain-linux binary)") return exitTool } binDir := filepath.Dir(nwmain) // 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 { fmt.Fprintln(stderr, "assets compile:", err) return exitUsage } failed := false for _, src := range files { if err := compileOne(src, binDir, nwmain, dev, mc, wrap, userData, stdout, stderr); err != nil { fmt.Fprintf(stderr, "assets compile: %s: %v\n", src, err) failed = true } } if failed { return exitFail } return exitOK } // findNWMain resolves the nwmain-linux binary from --nwn or standard roots. func findNWMain(override, home string) string { if override != "" { if fi, err := os.Stat(override); err == nil && !fi.IsDir() { return override } cand := filepath.Join(override, "bin", "linux-x86", "nwmain-linux") if fi, err := os.Stat(cand); err == nil && !fi.IsDir() { return cand } return "" } return look( filepath.Join(home, ".local/share/Steam/steamapps/common/Neverwinter Nights/bin/linux-x86/nwmain-linux"), filepath.Join(home, "GOG Games/Neverwinter Nights Enhanced Edition/game/bin/linux-x86/nwmain-linux"), filepath.Join(home, ".steam/steam/steamapps/common/Neverwinter Nights/bin/linux-x86/nwmain-linux"), ) } // compileOne compiles a single model. Binary models are reported and skipped // (not an error). A name-mismatched model is aborted with guidance. func compileOne(src, binDir, nwmain, dev, mc string, wrap []string, userData string, stdout, stderr io.Writer) error { ascii, err := mdl.IsASCII(src) if err != nil { return err } if !ascii { fmt.Fprintf(stdout, "skip (already compiled): %s\n", src) return nil } mismatches, err := mdl.CheckNames(src) if err != nil { return err } if len(mismatches) > 0 { return fmt.Errorf("model names differ from the file stem; run `crucible assets fix-mdl` first") } stem := mdl.ExpectedNameStem(src) name := strings.ToLower(filepath.Base(src)) devFile := filepath.Join(dev, name) // Collision guard: a pre-existing slot aborts before any mutation. if _, err := os.Stat(devFile); err == nil { return fmt.Errorf("development slot already occupied: %s", devFile) } data, err := os.ReadFile(src) if err != nil { return err } if err := os.WriteFile(devFile, data, 0o644); err != nil { return err } defer os.Remove(devFile) // Run the engine with cwd = the binary dir. cmd := append(append([]string{}, wrap...), nwmain, "compilemodel", stem) if out, err := runner(binDir, nil, cmd[0], cmd[1:]...); err != nil { printEngineLog(userData, stderr) return fmt.Errorf("engine: %v: %s", err, strings.TrimSpace(string(out))) } // Collect the compiled artifact from modelcompiler/ (match by stem, any case). compiled, err := findCompiled(mc, stem) if err != nil { printEngineLog(userData, stderr) return err } defer os.Remove(compiled) out, err := os.ReadFile(compiled) if err != nil { return err } // Move it back over the source path, lowercased. dst := filepath.Join(filepath.Dir(src), name) if err := os.WriteFile(dst, out, 0o644); err != nil { return err } if dst != src { _ = os.Remove(src) } fmt.Fprintf(stdout, "compiled: %s\n", dst) return nil } // findCompiled returns the modelcompiler/ artifact whose stem matches (case- // insensitively). func findCompiled(mc, stem string) (string, error) { entries, err := os.ReadDir(mc) if err != nil { return "", err } want := strings.ToLower(stem) + ".mdl" for _, e := range entries { if e.IsDir() { continue } if strings.ToLower(e.Name()) == want { return filepath.Join(mc, e.Name()), nil } } return "", fmt.Errorf("engine produced no compiled model for %q", stem) } // printEngineLog appends a short tail of the engine log as advisory diagnostics. func printEngineLog(userData string, stderr io.Writer) { log := filepath.Join(userData, "logs", "nwengineLog.txt") data, err := os.ReadFile(log) if err != nil { return } lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") if len(lines) > 8 { lines = lines[len(lines)-8:] } fmt.Fprintf(stderr, " engine log tail (%s):\n", log) for _, l := range lines { fmt.Fprintf(stderr, " %s\n", l) } }