Files
sow-tools/docs/superpowers/plans/2026-07-12-crucible-assets-builder.md
T
archvillainette 5ebef57160
build-binaries / build-binaries (push) Successful in 2m5s
test / test (push) Successful in 1m23s
assets builder tools (#39)
Reviewed-on: #39
Reviewed-by: xtul <mpiasecki720@protonmail.com>
2026-07-12 12:51:17 +00:00

74 KiB
Raw Blame History

Crucible assets Builder Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a self-contained crucible assets builder with seven commands (compile, convert, upscale, check-mdl, fix-mdl, check-dupes, clean-dupes) that fold the NWN:EE asset tools into Crucible.

Architecture: A new internal/assets package exposes Run(args, stdout, stderr, getenv) int, mirroring internal/depot exactly. internal/dispatch gets one Registry entry and one added direct-delegate branch so crucible assets … routes to assets.Run instead of the legacy app.Run. A pure-Go internal/assets/mdl package ports the model-name logic from mdl-name-lib.sh / mdl-scan.awk. External tools (the NWN engine, ImageMagick, an upscaler) are discovered on PATH / standard install roots and invoked through one swappable runner indirection so tests never call them for real.

Tech Stack: Go (standard library only — os/exec, flag, image/png in tests). ImageMagick magick for convert. The NWN:EE engine for compile. ncnn-vulkan upscalers for upscale.

Global Constraints

  • Go module path: git.westgate.pw/ShadowsOverWestgate/sow-tools. All internal imports use this prefix.
  • No configuration files. Arguments only. No staging dirs, YAML, or reports. Operate in place on the target directory.
  • Fail closed, never fake (Crucible rule #1). A missing backend is a clear error naming what to install and a non-zero exit — never a placeholder artifact.
  • Exit codes (assets-local, sysexits-style): 0 OK, 1 problems found / per-file failures, 64 usage (unknown subcommand, bad flags), 70 missing external tool / internal error.
  • assets.Run signature is exactly func Run(args []string, stdout, stderr io.Writer, getenv func(string) string) int — identical to depot.Run. No direct use of os.Stdout/os.Getenv inside command logic; everything flows through the passed writers and getenv.
  • External-command calls go through the package-level runner variable (defined in Task 2) so tests swap it. Never call exec.Command directly outside runner.
  • make check (go vet + go test + shellcheck + yamllint) must stay green. make smoke must include assets in its wired list.
  • Follow the existing code style: package doc comments, lowercase-first error strings, table-driven tests, t.TempDir() for fixtures.

File Structure

New files:

  • internal/assets/mdl/mdl.go — pure-Go model-name module (IsASCII, ExpectedName, CheckNames, FixNames).
  • internal/assets/mdl/mdl_test.go — table-driven parity fixtures.
  • internal/assets/run.goRun dispatch, usage, exit constants.
  • internal/assets/helpers.gowalk, look, runner.
  • internal/assets/dupes.gocheck-dupes + clean-dupes.
  • internal/assets/checkmdl.gocheck-mdl.
  • internal/assets/fixmdl.gofix-mdl.
  • internal/assets/convert.goconvert + shared ddsToPNG/pngToDDS helpers.
  • internal/assets/upscale.goupscale.
  • internal/assets/compile.gocompile.
  • internal/assets/*_test.go — one test file per command file above.
  • cmd/crucible-assets/main.go — one-line shim.

Modified files:

  • internal/dispatch/dispatch.go — Registry entry + delegate branch.
  • internal/dispatch/dispatch_test.go — extend canonical-surface and metadata tests.
  • scripts/crucible-smoke.sh — add assets to wired list.
  • docs/command-surface.md — seven visible-command rows.

Task 1: internal/assets/mdl model-name module

Files:

  • Create: internal/assets/mdl/mdl.go
  • Test: internal/assets/mdl/mdl_test.go

Interfaces:

  • Produces:
    • type Mismatch struct { Line int; What string; Got string }Line is 1-based, 0 for the geometry base-node mismatch (which has no line).
    • func IsASCII(path string) (bool, error)
    • func ExpectedName(path string) string
    • func CheckNames(path string) ([]Mismatch, error)
    • func FixNames(in []byte, expected string) (out []byte, changed bool)

Detection must match mdl-scan.awk (../sow-assets-manifest/scripts/mdl-scan.awk) because that awk keeps running in sow-assets-manifest until a follow-up spec retires it. Comparisons are case-insensitive against the file stem.

  • Step 1: Write the failing test

Create internal/assets/mdl/mdl_test.go:

package mdl

import (
	"os"
	"path/filepath"
	"testing"
)

func writeMDL(t *testing.T, name, body string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), name)
	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestIsASCII(t *testing.T) {
	ascii := writeMDL(t, "foo.mdl", "newmodel foo\nbeginmodelgeom foo\n")
	if ok, err := IsASCII(ascii); err != nil || !ok {
		t.Fatalf("ascii: ok=%v err=%v, want true nil", ok, err)
	}
	bin := writeMDL(t, "bar.mdl", "\x00\x01binary\x00garbage")
	if ok, err := IsASCII(bin); err != nil || ok {
		t.Fatalf("binary: ok=%v err=%v, want false nil", ok, err)
	}
	hash := writeMDL(t, "baz.mdl", "# a comment\nnewmodel baz\n")
	if ok, _ := IsASCII(hash); !ok {
		t.Fatal("leading # should be ascii")
	}
}

func TestExpectedName(t *testing.T) {
	for in, want := range map[string]string{
		"a/b/Foo.MDL": "Foo",
		"waxbt_b_091.mdl": "waxbt_b_091",
		"x.mdl": "x",
	} {
		if got := ExpectedName(in); got != want {
			t.Errorf("ExpectedName(%q)=%q want %q", in, got, want)
		}
	}
}

func TestCheckNames(t *testing.T) {
	// Header token mismatch + base-node mismatch in one file.
	body := "newmodel wrong\n" +
		"setsupermodel wrong a_base\n" +
		"beginmodelgeom foo\n" +
		"  node dummy Wmgst_m_081\n" +
		"    parent null\n" +
		"  endnode\n" +
		"endmodelgeom foo\n" +
		"donemodel foo\n"
	p := writeMDL(t, "foo.mdl", body)
	got, err := CheckNames(p)
	if err != nil {
		t.Fatal(err)
	}
	// Expected stem "foo": newmodel(wrong), setsupermodel(wrong), base node(Wmgst_m_081).
	if len(got) != 3 {
		t.Fatalf("got %d mismatches, want 3: %+v", len(got), got)
	}
	if got[0].What != "newmodel" || got[0].Got != "wrong" || got[0].Line != 1 {
		t.Errorf("first mismatch = %+v", got[0])
	}
	base := got[len(got)-1]
	if base.What != "root node" || base.Got != "Wmgst_m_081" || base.Line != 0 {
		t.Errorf("base mismatch = %+v", base)
	}

	// Clean model -> no mismatches.
	clean := writeMDL(t, "bar.mdl",
		"newmodel bar\nbeginmodelgeom bar\n  node dummy bar\n    parent null\n  endnode\nendmodelgeom bar\ndonemodel bar\n")
	if got, _ := CheckNames(clean); len(got) != 0 {
		t.Fatalf("clean model reported mismatches: %+v", got)
	}

	// Binary model -> nil, no error.
	bin := writeMDL(t, "b.mdl", "\x00\x00binary")
	if got, _ := CheckNames(bin); got != nil {
		t.Fatalf("binary reported mismatches: %+v", got)
	}
}

func TestFixNamesRoundTrips(t *testing.T) {
	body := "newmodel wrong\n" +
		"setsupermodel wrong\n" +
		"beginmodelgeom wrong\n" +
		"  node dummy Wmgst_m_081\n" +
		"    parent null\n" +
		"  endnode\n" +
		"endmodelgeom wrong\n" +
		"donemodel wrong\n"
	out, changed := FixNames([]byte(body), "foo")
	if !changed {
		t.Fatal("expected changed=true")
	}
	p := filepath.Join(t.TempDir(), "foo.mdl")
	if err := os.WriteFile(p, out, 0o644); err != nil {
		t.Fatal(err)
	}
	got, err := CheckNames(p)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 0 {
		t.Fatalf("fixed model still has mismatches: %+v\n%s", got, out)
	}
	// Idempotent: fixing a clean model changes nothing.
	if _, changed := FixNames(out, "foo"); changed {
		t.Fatal("second fix reported a change")
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/assets/mdl/ Expected: FAIL — package/functions do not exist.

  • Step 3: Write the implementation

Create internal/assets/mdl/mdl.go:

// Package mdl ports the ASCII MDL model-name checks from sow-assets-manifest's
// mdl-name-lib.sh + mdl-scan.awk to pure Go. Binary (compiled) MDLs are opaque
// here; the NWN engine is the validator for those. Detection mirrors
// mdl-scan.awk exactly so behavior does not drift while that awk still runs in
// sow-assets-manifest.
package mdl

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
)

// Mismatch is one model-name problem. Line is 1-based; 0 means the geometry
// base-node mismatch, which the source has no single line for.
type Mismatch struct {
	Line int
	What string
	Got  string
}

// ExpectedName is the file stem with a trailing ".mdl" (any case) removed.
func ExpectedName(path string) string {
	base := filepath.Base(path)
	if len(base) >= 4 && strings.EqualFold(base[len(base)-4:], ".mdl") {
		return base[:len(base)-4]
	}
	return base
}

// IsASCII reports whether path is an uncompiled ASCII model: no NUL in the
// first 256 bytes, and a leading keyword of '#' / newmodel / node /
// setsupermodel. Mirrors mdl_is_ascii.
func IsASCII(path string) (bool, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return false, err
	}
	return isASCII(data), nil
}

func isASCII(data []byte) bool {
	head := data
	if len(head) > 256 {
		head = head[:256]
	}
	if bytes.IndexByte(head, 0) >= 0 {
		return false
	}
	line := head
	if i := bytes.IndexByte(line, '\n'); i >= 0 {
		line = line[:i]
	}
	trimmed := strings.TrimLeft(string(line), " \t\r")
	if strings.HasPrefix(trimmed, "#") {
		return true
	}
	fields := strings.Fields(trimmed)
	if len(fields) == 0 {
		return false
	}
	tok := strings.ToLower(fields[0])
	return strings.HasPrefix(tok, "newmodel") ||
		strings.HasPrefix(tok, "node") ||
		strings.HasPrefix(tok, "setsupermodel")
}

// lineFields splits a line into whitespace-delimited tokens with the trailing
// CR removed, matching awk's default field split + \r strip.
func lineFields(line string) []string {
	return strings.Fields(strings.TrimRight(line, "\r"))
}

// field returns the 1-based nth field or "" if absent.
func field(f []string, n int) string {
	if n >= 1 && n <= len(f) {
		return f[n-1]
	}
	return ""
}

// CheckNames returns every model-name mismatch in an ASCII model. Returns nil
// for a binary model or one with no newmodel line (not a real model).
func CheckNames(path string) ([]Mismatch, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if !isASCII(data) {
		return nil, nil
	}
	return checkNames(data, ExpectedName(path)), nil
}

func checkNames(data []byte, expected string) []Mismatch {
	expLower := strings.ToLower(expected)
	var out []Mismatch
	seenModel := false
	inGeom := false
	curNode := ""
	base := ""
	baseFound := false

	// header records one header-token mismatch (field n, 1-based) if the token
	// is present and differs case-insensitively from the stem.
	lines := strings.Split(string(data), "\n")
	for i, raw := range lines {
		f := lineFields(raw)
		if len(f) == 0 {
			continue
		}
		key := strings.ToLower(f[0])
		mis := func(what string, n int) {
			got := field(f, n)
			if got != "" && !strings.EqualFold(got, expected) {
				out = append(out, Mismatch{Line: i + 1, What: what, Got: got})
			}
		}
		switch key {
		case "newmodel":
			seenModel = true
			mis("newmodel", 2)
		case "setsupermodel":
			mis("setsupermodel model", 2)
		case "beginmodelgeom":
			inGeom = true
			mis("beginmodelgeom", 2)
		case "endmodelgeom":
			inGeom = false
			mis("endmodelgeom", 2)
		case "donemodel":
			mis("donemodel", 2)
		case "newanim":
			mis("newanim model", 3)
		case "doneanim":
			mis("doneanim model", 3)
		case "node":
			if inGeom {
				curNode = field(f, 3)
			}
		case "parent":
			if inGeom && !baseFound && strings.EqualFold(field(f, 2), "null") {
				base = curNode
				baseFound = true
			}
		}
	}
	if !seenModel {
		return nil
	}
	if baseFound && base != "" && !strings.EqualFold(base, expLower) {
		out = append(out, Mismatch{Line: 0, What: "root node", Got: base})
	}
	return out
}

// FixNames rewrites only the model identity to expected: the header tokens plus
// the geometry base node's declaration / any parent / animroot pointing at the
// old base name. Animation bone names and supermodel animroots are left alone
// so inheritance keeps working. Mirrors mdl_fix_model_name in intent.
//
// ponytail: a rewritten line is rebuilt joining fields with single spaces and
// drops a trailing CR, exactly like the awk it replaces; unchanged lines are
// byte-identical. The engine ignores identity-line whitespace, and FixNames'
// contract is only "produces a model that passes CheckNames", not byte parity.
func FixNames(in []byte, expected string) (out []byte, changed bool) {
	if !isASCII(in) {
		return in, false
	}
	// oldbase: the base-node name to rename, only if it differs from expected.
	oldbase := ""
	for _, m := range checkNames(in, expected) {
		if m.What == "root node" {
			oldbase = m.Got
		}
	}

	lines := strings.Split(string(in), "\n")
	for idx, raw := range lines {
		f := lineFields(raw)
		if len(f) == 0 {
			continue
		}
		key := strings.ToLower(f[0])
		set := func(n int) bool {
			if field(f, n) == "" {
				return false
			}
			f[n-1] = expected
			return true
		}
		modified := false
		switch key {
		case "newmodel", "setsupermodel", "beginmodelgeom", "endmodelgeom", "donemodel":
			modified = set(2)
		case "newanim", "doneanim":
			modified = set(3)
		case "node":
			if oldbase != "" && strings.EqualFold(field(f, 3), oldbase) {
				modified = set(3)
			}
		case "parent", "animroot":
			if oldbase != "" && strings.EqualFold(field(f, 2), oldbase) {
				modified = set(2)
			}
		}
		if modified {
			lines[idx] = strings.Join(f, " ")
		}
	}
	out = []byte(strings.Join(lines, "\n"))
	return out, !bytes.Equal(out, in)
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/assets/mdl/ Expected: PASS.

  • Step 5: Commit
git add internal/assets/mdl/
git commit -m "feat(assets): pure-Go MDL model-name module"

Task 2: Shared helpers + Run dispatch skeleton

Files:

  • Create: internal/assets/helpers.go
  • Create: internal/assets/run.go
  • Test: internal/assets/helpers_test.go
  • Test: internal/assets/run_test.go

Interfaces:

  • Consumes: nothing.

  • Produces:

    • func Run(args []string, stdout, stderr io.Writer, getenv func(string) string) int
    • Exit constants: exitOK=0, exitFail=1, exitUsage=64, exitTool=70.
    • func walk(roots []string, exts map[string]bool, recursive bool) ([]string, error) — files under each root whose lower-cased extension is in exts; a root that is itself a matching file is included; sorted, deduplicated.
    • func look(candidates ...string) string — first bare name found on PATH, or first candidate path that exists; "" if none.
    • var runner = func(dir string, env []string, name string, args ...string) ([]byte, error) — the single external-command indirection (combined output). Swapped in tests.
  • Step 1: Write the failing tests

Create internal/assets/helpers_test.go:

package assets

import (
	"os"
	"path/filepath"
	"testing"
)

func TestWalk(t *testing.T) {
	root := t.TempDir()
	must := func(rel string) {
		p := filepath.Join(root, rel)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	must("a.MDL")
	must("sub/b.mdl")
	must("sub/c.txt")

	exts := map[string]bool{".mdl": true}
	got, err := walk([]string{root}, exts, true)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 {
		t.Fatalf("recursive walk = %v, want 2 mdl files", got)
	}

	got, _ = walk([]string{root}, exts, false)
	if len(got) != 1 || filepath.Base(got[0]) != "a.MDL" {
		t.Fatalf("non-recursive walk = %v, want only a.MDL", got)
	}

	// A file argument that matches is included directly.
	got, _ = walk([]string{filepath.Join(root, "sub", "b.mdl")}, exts, true)
	if len(got) != 1 {
		t.Fatalf("file arg walk = %v, want the file itself", got)
	}
}

func TestLook(t *testing.T) {
	dir := t.TempDir()
	stub := filepath.Join(dir, "mytool")
	if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", dir)

	if got := look("nope-not-here", "mytool"); got == "" {
		t.Fatal("look should find mytool on PATH")
	}
	// Explicit existing path candidate.
	if got := look(stub); got != stub {
		t.Fatalf("look(%q) = %q, want the path itself", stub, got)
	}
	if got := look("definitely-absent-binary-xyz"); got != "" {
		t.Fatalf("look = %q, want empty", got)
	}
}

Create internal/assets/run_test.go:

package assets

import (
	"bytes"
	"strings"
	"testing"
)

func env(string) string { return "" }

func TestRunNoArgsIsUsage(t *testing.T) {
	var out, errw bytes.Buffer
	if code := Run(nil, &out, &errw, env); code != exitUsage {
		t.Fatalf("no args exit = %d, want %d", code, exitUsage)
	}
	if !strings.Contains(errw.String(), "usage") {
		t.Fatalf("no args should print usage, got: %q", errw.String())
	}
}

func TestRunUnknownSubcommandIsUsage(t *testing.T) {
	var out, errw bytes.Buffer
	if code := Run([]string{"frobnicate"}, &out, &errw, env); code != exitUsage {
		t.Fatalf("unknown subcommand exit = %d, want %d", code, exitUsage)
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./internal/assets/ Expected: FAIL — assets package has no source files yet.

  • Step 3: Write the helpers

Create internal/assets/helpers.go:

// Package assets is the crucible `assets` builder: NWN:EE model/texture tools
// that operate in place on a target directory. Mirrors internal/depot's
// Run(args, stdout, stderr, getenv) shape and bypasses the legacy internal/app
// surface entirely.
package assets

import (
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
)

// runner is the single external-command indirection so tests can observe and
// stub every engine / ImageMagick / upscaler invocation. dir is the working
// directory ("" = inherit); env replaces the child environment when non-nil.
var runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
	cmd := exec.Command(name, args...)
	cmd.Dir = dir
	if env != nil {
		cmd.Env = env
	}
	return cmd.CombinedOutput()
}

// walk collects files under each root whose lower-cased extension is in exts. A
// root that is itself a matching file is included. recursive controls descent
// into subdirectories. Results are sorted and deduplicated.
func walk(roots []string, exts map[string]bool, recursive bool) ([]string, error) {
	seen := map[string]bool{}
	var out []string
	add := func(p string) {
		if exts[strings.ToLower(filepath.Ext(p))] && !seen[p] {
			seen[p] = true
			out = append(out, p)
		}
	}
	for _, root := range roots {
		fi, err := os.Stat(root)
		if err != nil {
			return nil, err
		}
		if !fi.IsDir() {
			add(root)
			continue
		}
		err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				if !recursive && p != root {
					return filepath.SkipDir
				}
				return nil
			}
			add(p)
			return nil
		})
		if err != nil {
			return nil, err
		}
	}
	sort.Strings(out)
	return out, nil
}

// look returns the first bare name found on PATH, or the first candidate that
// is an existing path. Returns "" if none resolve.
func look(candidates ...string) string {
	for _, c := range candidates {
		if strings.ContainsRune(c, filepath.Separator) {
			if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
				return c
			}
			continue
		}
		if p, err := exec.LookPath(c); err == nil {
			return p
		}
	}
	return ""
}
  • Step 4: Write the Run dispatch skeleton

Create internal/assets/run.go:

package assets

import (
	"fmt"
	"io"
)

const (
	exitOK    = 0
	exitFail  = 1  // problems found / per-file failures
	exitUsage = 64 // bad invocation / unknown subcommand / bad flags
	exitTool  = 70 // missing external tool / internal error
)

// Run executes an assets subcommand. args[0] is the subcommand; returns the
// process exit code.
func Run(args []string, stdout, stderr io.Writer, getenv func(string) string) int {
	if len(args) == 0 {
		printUsage(stderr)
		return exitUsage
	}
	rest := args[1:]
	switch args[0] {
	case "check-dupes":
		return runCheckDupes(rest, stdout, stderr)
	case "clean-dupes":
		return runCleanDupes(rest, stdout, stderr)
	case "check-mdl":
		return runCheckMDL(rest, stdout, stderr)
	case "fix-mdl":
		return runFixMDL(rest, stdout, stderr)
	case "convert":
		return runConvert(rest, stdout, stderr)
	case "upscale":
		return runUpscale(rest, stdout, stderr)
	case "compile":
		return runCompile(rest, stdout, stderr, getenv)
	default:
		fmt.Fprintf(stderr, "assets: unknown subcommand %q\n\n", args[0])
		printUsage(stderr)
		return exitUsage
	}
}

func printUsage(w io.Writer) {
	fmt.Fprint(w, `usage:
  assets compile      [--nwn INSTALL] [--non-recursive] <dir>...
  assets convert      [--to dds|png|tga] [--backend PATH] [--non-recursive] <dir>...
  assets upscale      [--scale N] [--backend PATH] [--non-recursive] <dir>...
  assets check-mdl    <path>...
  assets fix-mdl      [--dry-run] <path>...
  assets check-dupes  <dir>...
  assets clean-dupes  [--dry-run] <primary> <clean>
`)
}

Note: run.go references runCheckDupes, runConvert, etc. which do not exist yet. The package will not compile until Task 3+ add them. To keep this task independently green, add temporary stubs in run.go now and delete each as its real task lands:

// --- temporary stubs; removed as each command task lands ---
func runCheckDupes(_ []string, _, _ io.Writer) int        { return exitTool }
func runCleanDupes(_ []string, _, _ io.Writer) int         { return exitTool }
func runCheckMDL(_ []string, _, _ io.Writer) int           { return exitTool }
func runFixMDL(_ []string, _, _ io.Writer) int             { return exitTool }
func runConvert(_ []string, _, _ io.Writer) int            { return exitTool }
func runUpscale(_ []string, _, _ io.Writer) int            { return exitTool }
func runCompile(_ []string, _, _ io.Writer, _ func(string) string) int { return exitTool }
  • Step 5: Run tests to verify they pass

Run: go test ./internal/assets/ Expected: PASS.

  • Step 6: Commit
git add internal/assets/helpers.go internal/assets/run.go internal/assets/helpers_test.go internal/assets/run_test.go
git commit -m "feat(assets): Run dispatch skeleton + walk/look/runner helpers"

Task 3: Wire assets into the dispatch registry, shim, smoke, docs

Files:

  • Modify: internal/dispatch/dispatch.go (Registry: add assets entry; runBuilder: extend the direct-delegate branch)
  • Modify: internal/dispatch/dispatch_test.go (TestCanonicalCommandSurface, TestRegistryCommandNamesAndAliasesAreUnambiguous)
  • Create: cmd/crucible-assets/main.go
  • Modify: scripts/crucible-smoke.sh
  • Modify: docs/command-surface.md

Interfaces:

  • Consumes: assets.Run from Task 2.

  • Produces: crucible assets … and crucible-assets … as a wired builder.

  • Step 1: Add the Registry entry

In internal/dispatch/dispatch.go, add this builder to the Registry slice (place it after the depot entry). Every command needs Name, Summary, Usage (metadata test requires them); no AppCommand (assets bypasses legacy routing):

	{
		Name:    "assets",
		Bin:     "crucible-assets",
		Summary: "compile/convert/upscale NWN assets + mdl/dupe integrity",
		Commands: []Command{
			{Name: "compile", Summary: "compile ASCII .mdl models to binary in place", Usage: "crucible assets compile [--nwn INSTALL] [--non-recursive] <dir>..."},
			{Name: "convert", Summary: "convert textures to/from NWN DDS (flips vertically)", Usage: "crucible assets convert [--to dds|png|tga] [--backend PATH] [--non-recursive] <dir>..."},
			{Name: "upscale", Summary: "upscale textures through an installed backend", Usage: "crucible assets upscale [--scale N] [--backend PATH] [--non-recursive] <dir>..."},
			{Name: "check-mdl", Summary: "report uncompiled ASCII .mdl and model-name mismatches", Usage: "crucible assets check-mdl <path>..."},
			{Name: "fix-mdl", Summary: "lowercase .mdl names and rewrite model identity to match", Usage: "crucible assets fix-mdl [--dry-run] <path>..."},
			{Name: "check-dupes", Summary: "report runtime-name (basename) collisions across dirs", Usage: "crucible assets check-dupes <dir>..."},
			{Name: "clean-dupes", Summary: "delete clean-tree files whose basename collides with primary", Usage: "crucible assets clean-dupes [--dry-run] <primary> <clean>"},
		},
		Wired: true,
	},
  • Step 2: Extend the direct-delegate branch

In internal/dispatch/dispatch.go, add the assets import:

	"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets"

Replace the depot special-case block in runBuilder:

	if b.Name == "depot" && b.Wired {
		// depot parses its own subcommand (status/push/verify/get/pull) and
		// has its own richer exit contract (0/1/2/64/70), so it bypasses the
		// b.Commands/delegateLegacy routing entirely.
		return depot.Run(args, out, errw, os.Getenv)
	}

with:

	if b.Wired {
		// depot and assets are self-contained builders: they parse their own
		// subcommands and own their exit contract, bypassing the
		// b.Commands/delegateLegacy legacy routing entirely.
		switch b.Name {
		case "depot":
			return depot.Run(args, out, errw, os.Getenv)
		case "assets":
			return assets.Run(args, out, errw, os.Getenv)
		}
	}
  • Step 3: Add the standalone shim

Create cmd/crucible-assets/main.go:

// Command crucible-assets is the standalone assets builder (equivalent to
// `crucible assets`). A single-token binary keeps consumer wrapper scripts simple.
package main

import (
	"os"

	"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch"
)

func main() { os.Exit(dispatch.RunBuilder("assets", os.Args[1:])) }
  • Step 4: Run the dispatch guard tests and watch them fail

Run: go test ./internal/dispatch/ Expected: FAIL. TestCanonicalCommandSurface fatals with unexpected builder "assets" (the new builder is not in its want map), and TestRegistryCommandNamesAndAliasesAreUnambiguous errors that assets commands have an empty AppCommand. These guard tests must be updated to admit the new self-contained builder.

  • Step 5: Update the dispatch guard tests

In internal/dispatch/dispatch_test.go, extend the want map in TestCanonicalCommandSurface to add:

		"assets": {"compile", "convert", "upscale", "check-mdl", "fix-mdl", "check-dupes", "clean-dupes"},

In TestRegistryCommandNamesAndAliasesAreUnambiguous, change the AppCommand exemption so assets (like depot) is not required to carry an AppCommand:

			requireAppCommand := builder.Name != "depot" && builder.Name != "assets"

Run: go test ./internal/dispatch/ Expected: PASS.

  • Step 6: Add assets to the smoke wired list

In scripts/crucible-smoke.sh, change:

wired=(depot hak module topdata wiki)

to:

wired=(assets depot hak module topdata wiki)
  • Step 7: Add the seven doc rows

In docs/command-surface.md, under the ## Visible commands table (after the last row), add:

| `assets` | `compile` | Compile ASCII `.mdl` models to binary in place. |
| `assets` | `convert` | Convert textures to/from NWN DDS (flips vertically). |
| `assets` | `upscale` | Upscale textures through an installed backend. |
| `assets` | `check-mdl` | Report uncompiled ASCII `.mdl` and model-name mismatches. |
| `assets` | `fix-mdl` | Lowercase `.mdl` names and rewrite model identity to match. |
| `assets` | `check-dupes` | Report runtime-name (basename) collisions across dirs. |
| `assets` | `clean-dupes` | Delete clean-tree files whose basename collides with primary. |
  • Step 8: Build every package

Run: go build ./... Expected: clean build (the new cmd/crucible-assets and the dispatch changes compile).

  • Step 9: Run the smoke test

Run: make smoke Expected: output ends with smoke ok: 0 unwired fail closed (70), 6 wired delegate to nwn-tool logic (assets --help exits 0; no-subcommand exits 64, not 70).

  • Step 10: Commit
git add internal/dispatch/ cmd/crucible-assets/ scripts/crucible-smoke.sh docs/command-surface.md
git commit -m "feat(assets): wire builder into dispatch, shim, smoke, docs"

Task 4: check-dupes + clean-dupes

Files:

  • Create: internal/assets/dupes.go
  • Test: internal/assets/dupes_test.go
  • Modify: internal/assets/run.go (remove the two dupes stubs)

Interfaces:

  • Consumes: exitOK/exitFail/exitUsage, walk — not needed here (dupes walk all files, not by extension); uses its own full-tree scan.
  • Produces: func runCheckDupes(args []string, stdout, stderr io.Writer) int, func runCleanDupes(args []string, stdout, stderr io.Writer) int.

Ports check-duplicate-names.sh (directory mode only) and clean-duplicate-names.sh. NWN packs hak entries by basename, so files at different paths with the same lower-cased basename silently clobber.

  • Step 1: Write the failing tests

Create internal/assets/dupes_test.go:

package assets

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"
)

func touch(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestCheckDupes(t *testing.T) {
	root := t.TempDir()
	touch(t, filepath.Join(root, "tex", "Foo.tga"))
	touch(t, filepath.Join(root, "plc", "foo.tga")) // basename collision (case-insensitive)

	var out, errw bytes.Buffer
	if code := runCheckDupes([]string{root}, &out, &errw); code != exitFail {
		t.Fatalf("collision exit = %d, want %d\n%s", code, exitFail, errw.String())
	}

	clean := t.TempDir()
	touch(t, filepath.Join(clean, "a.tga"))
	touch(t, filepath.Join(clean, "sub", "b.tga"))
	out.Reset()
	errw.Reset()
	if code := runCheckDupes([]string{clean}, &out, &errw); code != exitOK {
		t.Fatalf("no-collision exit = %d, want %d\n%s", code, exitOK, errw.String())
	}
}

func TestCleanDupes(t *testing.T) {
	primary := t.TempDir()
	clean := t.TempDir()
	touch(t, filepath.Join(primary, "keep.tga"))
	collide := filepath.Join(clean, "sub", "Keep.tga")
	survive := filepath.Join(clean, "unique.tga")
	touch(t, collide)
	touch(t, survive)

	// dry-run removes nothing.
	var out, errw bytes.Buffer
	if code := runCleanDupes([]string{"--dry-run", primary, clean}, &out, &errw); code != exitOK {
		t.Fatalf("dry-run exit = %d\n%s", code, errw.String())
	}
	if _, err := os.Stat(collide); err != nil {
		t.Fatal("dry-run deleted a file")
	}

	// real run deletes the collision, keeps the unique file.
	out.Reset()
	errw.Reset()
	if code := runCleanDupes([]string{primary, clean}, &out, &errw); code != exitOK {
		t.Fatalf("clean exit = %d\n%s", code, errw.String())
	}
	if _, err := os.Stat(collide); !os.IsNotExist(err) {
		t.Fatal("collision file was not deleted")
	}
	if _, err := os.Stat(survive); err != nil {
		t.Fatal("unique file was wrongly deleted")
	}
}

func TestCleanDupesRejectsOverlap(t *testing.T) {
	root := t.TempDir()
	sub := filepath.Join(root, "child")
	touch(t, filepath.Join(sub, "x.tga"))
	var out, errw bytes.Buffer
	if code := runCleanDupes([]string{root, sub}, &out, &errw); code != exitUsage {
		t.Fatalf("overlap exit = %d, want %d", code, exitUsage)
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./internal/assets/ -run Dupes Expected: FAIL — the stubs return exitTool.

  • Step 3: Implement dupes

First remove the two stub lines runCheckDupes and runCleanDupes from internal/assets/run.go. Then create internal/assets/dupes.go:

package assets

import (
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// allFiles returns every regular file under root (recursive).
func allFiles(root string) ([]string, error) {
	var out []string
	err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			out = append(out, p)
		}
		return nil
	})
	sort.Strings(out)
	return out, err
}

// runCheckDupes reports files whose lower-cased basename collides across the
// given dirs. Read-only. Exit exitFail if any collision is found.
func runCheckDupes(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("check-dupes", flag.ContinueOnError)
	fs.SetOutput(stderr)
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	dirs := fs.Args()
	if len(dirs) == 0 {
		fmt.Fprintln(stderr, "assets check-dupes: usage: check-dupes <dir>...")
		return exitUsage
	}

	first := map[string]string{}
	fail := false
	for _, dir := range dirs {
		if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
			fmt.Fprintf(stderr, "assets check-dupes: no such dir: %s\n", dir)
			return exitUsage
		}
		files, err := allFiles(dir)
		if err != nil {
			fmt.Fprintln(stderr, "assets check-dupes:", err)
			return exitTool
		}
		for _, f := range files {
			key := strings.ToLower(filepath.Base(f))
			if prev, ok := first[key]; ok {
				fmt.Fprintf(stderr, "runtime-name collision: %s collides with %s\n", f, prev)
				fail = true
			} else {
				first[key] = f
			}
		}
	}
	if fail {
		fmt.Fprintln(stderr, "check-dupes: found runtime-name collision(s)")
		return exitFail
	}
	fmt.Fprintln(stdout, "check-dupes: OK")
	return exitOK
}

// runCleanDupes deletes files from <clean> whose lower-cased basename collides
// with any file in <primary>. Mutates only <clean>. Refuses to run if the two
// trees overlap.
func runCleanDupes(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("clean-dupes", flag.ContinueOnError)
	fs.SetOutput(stderr)
	dryRun := fs.Bool("dry-run", false, "report deletions without removing")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	pos := fs.Args()
	if len(pos) != 2 {
		fmt.Fprintln(stderr, "assets clean-dupes: usage: clean-dupes [--dry-run] <primary> <clean>")
		return exitUsage
	}
	primary, clean := pos[0], pos[1]
	for _, d := range []string{primary, clean} {
		if fi, err := os.Stat(d); err != nil || !fi.IsDir() {
			fmt.Fprintf(stderr, "assets clean-dupes: no such dir: %s\n", d)
			return exitUsage
		}
	}
	primaryReal, err1 := filepath.EvalSymlinks(primary)
	cleanReal, err2 := filepath.EvalSymlinks(clean)
	if err1 != nil || err2 != nil {
		fmt.Fprintln(stderr, "assets clean-dupes: cannot resolve dirs")
		return exitTool
	}
	if overlaps(primaryReal, cleanReal) {
		fmt.Fprintln(stderr, "assets clean-dupes: primary and clean dirs must not overlap")
		return exitUsage
	}

	primaryFiles, err := allFiles(primary)
	if err != nil {
		fmt.Fprintln(stderr, "assets clean-dupes:", err)
		return exitTool
	}
	names := map[string]bool{}
	for _, f := range primaryFiles {
		names[strings.ToLower(filepath.Base(f))] = true
	}

	cleanFiles, err := allFiles(clean)
	if err != nil {
		fmt.Fprintln(stderr, "assets clean-dupes:", err)
		return exitTool
	}
	removed := 0
	for _, f := range cleanFiles {
		if !names[strings.ToLower(filepath.Base(f))] {
			continue
		}
		if *dryRun {
			fmt.Fprintf(stderr, "would delete clean-tree collision: %s\n", f)
		} else {
			if err := os.Remove(f); err != nil {
				fmt.Fprintln(stderr, "assets clean-dupes:", err)
				return exitTool
			}
			fmt.Fprintf(stderr, "deleted clean-tree collision: %s\n", f)
		}
		removed++
	}
	verb := "removed"
	if *dryRun {
		verb = "would remove"
	}
	fmt.Fprintf(stdout, "clean-dupes: %s %d file(s)\n", verb, removed)
	return exitOK
}

// overlaps reports whether either directory contains the other (or they are
// equal), using cleaned absolute-ish paths with a trailing separator.
func overlaps(a, b string) bool {
	as := a + string(filepath.Separator)
	bs := b + string(filepath.Separator)
	return strings.HasPrefix(as, bs) || strings.HasPrefix(bs, as)
}
  • Step 4: Run tests to verify they pass

Run: go test ./internal/assets/ Expected: PASS.

  • Step 5: Commit
git add internal/assets/dupes.go internal/assets/dupes_test.go internal/assets/run.go
git commit -m "feat(assets): check-dupes + clean-dupes basename-collision tools"

Task 5: check-mdl

Files:

  • Create: internal/assets/checkmdl.go
  • Test: internal/assets/checkmdl_test.go
  • Modify: internal/assets/run.go (remove the runCheckMDL stub)

Interfaces:

  • Consumes: internal/assets/mdl (IsASCII, ExpectedName, CheckNames), walk, exit constants.
  • Produces: func runCheckMDL(args []string, stdout, stderr io.Writer) int.

Read-only. Each <path> is a file or dir (recursed for *.mdl). Reports uncompiled ASCII models and every model-name mismatch. Exit exitFail if any is found.

  • Step 1: Write the failing test

Create internal/assets/checkmdl_test.go:

package assets

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"
)

func TestCheckMDL(t *testing.T) {
	dir := t.TempDir()
	// Uncompiled ASCII model with a name mismatch.
	bad := filepath.Join(dir, "foo.mdl")
	if err := os.WriteFile(bad, []byte("newmodel wrong\nbeginmodelgeom wrong\nendmodelgeom wrong\ndonemodel wrong\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	var out, errw bytes.Buffer
	if code := runCheckMDL([]string{dir}, &out, &errw); code != exitFail {
		t.Fatalf("bad mdl exit = %d, want %d\n%s", code, exitFail, errw.String())
	}

	// A binary (compiled) model is fine.
	good := t.TempDir()
	if err := os.WriteFile(filepath.Join(good, "bar.mdl"), []byte("\x00\x00compiled binary blob"), 0o644); err != nil {
		t.Fatal(err)
	}
	out.Reset()
	errw.Reset()
	if code := runCheckMDL([]string{good}, &out, &errw); code != exitOK {
		t.Fatalf("binary mdl exit = %d, want %d\n%s", code, exitOK, errw.String())
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/assets/ -run CheckMDL Expected: FAIL — stub returns exitTool.

  • Step 3: Implement check-mdl

Remove the runCheckMDL stub from run.go. Create internal/assets/checkmdl.go:

package assets

import (
	"flag"
	"fmt"
	"io"

	"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets/mdl"
)

// mdlExt is the file set shared by the mdl commands.
var mdlExt = map[string]bool{".mdl": true}

// runCheckMDL reports uncompiled ASCII .mdl files and model-name mismatches.
// Read-only. Exit exitFail if any problem is found.
func runCheckMDL(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("check-mdl", flag.ContinueOnError)
	fs.SetOutput(stderr)
	nonRecursive := fs.Bool("non-recursive", false, "do not descend into subdirectories")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	paths := fs.Args()
	if len(paths) == 0 {
		fmt.Fprintln(stderr, "assets check-mdl: usage: check-mdl <path>...")
		return exitUsage
	}

	files, err := walk(paths, mdlExt, !*nonRecursive)
	if err != nil {
		fmt.Fprintln(stderr, "assets check-mdl:", err)
		return exitUsage
	}

	fail := false
	for _, f := range files {
		ascii, err := mdl.IsASCII(f)
		if err != nil {
			fmt.Fprintln(stderr, "assets check-mdl:", err)
			return exitTool
		}
		if ascii {
			fmt.Fprintf(stderr, "uncompiled ascii mdl: %s\n", f)
			fail = true
		}
		mismatches, err := mdl.CheckNames(f)
		if err != nil {
			fmt.Fprintln(stderr, "assets check-mdl:", err)
			return exitTool
		}
		expected := mdl.ExpectedName(f)
		for _, m := range mismatches {
			if m.Line == 0 {
				fmt.Fprintf(stderr, "%s: mdl model-name mismatch: root node is %s, expected %s\n", f, m.Got, expected)
			} else {
				fmt.Fprintf(stderr, "%s: mdl model-name mismatch: line %d: %s is %s, expected %s\n", f, m.Line, m.What, m.Got, expected)
			}
			fail = true
		}
	}
	if fail {
		fmt.Fprintln(stderr, "check-mdl: found broken or uncompiled .mdl file(s)")
		return exitFail
	}
	fmt.Fprintln(stdout, "check-mdl: OK")
	return exitOK
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/assets/ Expected: PASS.

  • Step 5: Commit
git add internal/assets/checkmdl.go internal/assets/checkmdl_test.go internal/assets/run.go
git commit -m "feat(assets): check-mdl uncompiled/name-mismatch report"

Task 6: fix-mdl

Files:

  • Create: internal/assets/fixmdl.go
  • Test: internal/assets/fixmdl_test.go
  • Modify: internal/assets/run.go (remove the runFixMDL stub)

Interfaces:

  • Consumes: internal/assets/mdl (ExpectedName, CheckNames, FixNames, IsASCII), walk, mdlExt, exit constants.
  • Produces: func runFixMDL(args []string, stdout, stderr io.Writer) int.

For each *.mdl: lowercase the filename if needed (abort that file on a case-collision), then for ASCII models with a name mismatch rewrite the identity in place. --dry-run touches nothing.

  • Step 1: Write the failing test

Create internal/assets/fixmdl_test.go:

package assets

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"

	"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets/mdl"
)

func TestFixMDL(t *testing.T) {
	dir := t.TempDir()
	// Uppercase name + internal mismatch.
	upper := filepath.Join(dir, "Foo.MDL")
	body := "newmodel wrong\nbeginmodelgeom wrong\nendmodelgeom wrong\ndonemodel wrong\n"
	if err := os.WriteFile(upper, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}

	// dry-run: nothing changes on disk.
	var out, errw bytes.Buffer
	if code := runFixMDL([]string{"--dry-run", dir}, &out, &errw); code != exitOK {
		t.Fatalf("dry-run exit = %d\n%s", code, errw.String())
	}
	if _, err := os.Stat(upper); err != nil {
		t.Fatal("dry-run renamed a file")
	}

	// real run: file is lowercased and its identity rewritten to match.
	out.Reset()
	errw.Reset()
	if code := runFixMDL([]string{dir}, &out, &errw); code != exitOK {
		t.Fatalf("fix exit = %d\n%s", code, errw.String())
	}
	lowered := filepath.Join(dir, "foo.mdl")
	if _, err := os.Stat(lowered); err != nil {
		t.Fatalf("file was not lowercased: %v", err)
	}
	mismatches, err := mdl.CheckNames(lowered)
	if err != nil {
		t.Fatal(err)
	}
	if len(mismatches) != 0 {
		data, _ := os.ReadFile(lowered)
		t.Fatalf("model still has mismatches after fix: %+v\n%s", mismatches, data)
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/assets/ -run FixMDL Expected: FAIL — stub returns exitTool.

  • Step 3: Implement fix-mdl

Remove the runFixMDL stub from run.go. Create internal/assets/fixmdl.go:

package assets

import (
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets/mdl"
)

// runFixMDL lowercases .mdl filenames and rewrites ASCII model identity to
// match each file's stem. Binary models are skipped (the engine owns those).
func runFixMDL(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("fix-mdl", flag.ContinueOnError)
	fs.SetOutput(stderr)
	dryRun := fs.Bool("dry-run", false, "report changes without touching disk")
	nonRecursive := fs.Bool("non-recursive", false, "do not descend into subdirectories")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	paths := fs.Args()
	if len(paths) == 0 {
		fmt.Fprintln(stderr, "assets fix-mdl: usage: fix-mdl [--dry-run] <path>...")
		return exitUsage
	}

	files, err := walk(paths, mdlExt, !*nonRecursive)
	if err != nil {
		fmt.Fprintln(stderr, "assets fix-mdl:", err)
		return exitUsage
	}

	changed := 0
	failed := false
	for _, f := range files {
		// 1. Lowercase the basename if needed.
		//
		// ponytail: 35k files are each read once here (CheckNames + the rewrite
		// on candidates). The shell reference batched an awk pass to avoid
		// per-file subprocess spawns; in Go a plain read is cheap, so the batch
		// is not worth porting. If profiling ever shows this hot, parallelize
		// the loop.
		lower := f
		if base := filepath.Base(f); base != strings.ToLower(base) {
			lower = filepath.Join(filepath.Dir(f), strings.ToLower(base))
			if _, err := os.Stat(lower); err == nil {
				fmt.Fprintf(stderr, "assets fix-mdl: lowercase collision: %s -> %s\n", f, lower)
				failed = true
				continue
			}
			if *dryRun {
				fmt.Fprintf(stdout, "would lowercase: %s -> %s\n", f, lower)
			} else {
				if err := os.Rename(f, lower); err != nil {
					fmt.Fprintf(stderr, "assets fix-mdl: failed to lowercase %s: %v\n", f, err)
					failed = true
					continue
				}
				fmt.Fprintf(stdout, "lowercased: %s -> %s\n", f, lower)
			}
			changed++
		}

		// 2. Rewrite model identity if the (lowercased) file has a mismatch.
		//    In dry-run the on-disk file is still the original path.
		readPath := lower
		if *dryRun && lower != f {
			readPath = f
		}
		data, err := os.ReadFile(readPath)
		if err != nil {
			fmt.Fprintln(stderr, "assets fix-mdl:", err)
			failed = true
			continue
		}
		out, wasChanged := mdl.FixNames(data, mdl.ExpectedName(lower))
		if !wasChanged {
			continue
		}
		if *dryRun {
			fmt.Fprintf(stdout, "would fix: %s\n", lower)
		} else {
			if err := os.WriteFile(lower, out, 0o644); err != nil {
				fmt.Fprintln(stderr, "assets fix-mdl:", err)
				failed = true
				continue
			}
			fmt.Fprintf(stdout, "fixed: %s\n", lower)
		}
		changed++
	}

	if changed == 0 {
		fmt.Fprintln(stdout, "no broken mdl model names found")
	}
	if failed {
		return exitFail
	}
	return exitOK
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/assets/ Expected: PASS.

  • Step 5: Commit
git add internal/assets/fixmdl.go internal/assets/fixmdl_test.go internal/assets/run.go
git commit -m "feat(assets): fix-mdl lowercase + identity rewrite"

Task 7: convert

Files:

  • Create: internal/assets/convert.go
  • Test: internal/assets/convert_test.go
  • Modify: internal/assets/run.go (remove the runConvert stub)

Interfaces:

  • Consumes: walk, look, runner, exit constants.
  • Produces:
    • func runConvert(args []string, stdout, stderr io.Writer) int
    • func ddsToPNG(magickBin, src, dst string) error — decode DDS to PNG, flipping vertically once.
    • func pngToDDS(magickBin, src, dst string) error — encode PNG to DDS, flipping once, auto-selecting DXT1 (opaque) vs DXT5 (alpha). (Used by upscale in Task 8.)

Every conversion flips vertically exactly once (NWN convention). Backend is ImageMagick magick; --backend overrides. Missing backend → fail closed.

  • Step 1: Write the failing test

Create internal/assets/convert_test.go. This is a real round-trip using magick (present in the dev shell); it skips if magick is absent:

package assets

import (
	"bytes"
	"image"
	"image/color"
	"image/png"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
)

// writeTestPNG writes a 4x4 image: top two rows red, bottom two rows blue.
func writeTestPNG(t *testing.T, path string) {
	t.Helper()
	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	for y := 0; y < 4; y++ {
		c := color.RGBA{255, 0, 0, 255} // red
		if y >= 2 {
			c = color.RGBA{0, 0, 255, 255} // blue
		}
		for x := 0; x < 4; x++ {
			img.Set(x, y, c)
		}
	}
	f, err := os.Create(path)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	if err := png.Encode(f, img); err != nil {
		t.Fatal(err)
	}
}

func topRowIsBlue(t *testing.T, pngPath string) bool {
	t.Helper()
	f, err := os.Open(pngPath)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	img, err := png.Decode(f)
	if err != nil {
		t.Fatal(err)
	}
	r, _, b, _ := img.At(0, 0).RGBA()
	return b > r // blue dominates the top-left after a flip
}

func TestConvertFlipsOnceAndRoundTrips(t *testing.T) {
	magick := look("magick")
	if magick == "" {
		t.Skip("magick not on PATH")
	}
	dir := t.TempDir()
	src := filepath.Join(dir, "tex.png")
	writeTestPNG(t, src)

	// Convert PNG -> DDS (in place: tex.png becomes tex.dds, original removed).
	var out, errw bytes.Buffer
	if code := runConvert([]string{"--to", "dds", dir}, &out, &errw); code != exitOK {
		t.Fatalf("to-dds exit = %d\n%s", code, errw.String())
	}
	dds := filepath.Join(dir, "tex.dds")
	if _, err := os.Stat(dds); err != nil {
		t.Fatalf("dds not produced: %v", err)
	}
	if _, err := os.Stat(src); !os.IsNotExist(err) {
		t.Fatal("source png was not replaced")
	}

	// Decode the DDS RAW (no extra flip) and confirm a single flip happened:
	// the source top row was red, so the DDS top row must now be blue.
	raw := filepath.Join(dir, "raw.png")
	if err := exec.Command(magick, dds, raw).Run(); err != nil {
		t.Fatalf("raw decode: %v", err)
	}
	if !topRowIsBlue(t, raw) {
		t.Fatal("expected the DDS to be vertically flipped vs the source")
	}

	// Convert DDS -> PNG (another flip). Result should match the original.
	out.Reset()
	errw.Reset()
	if code := runConvert([]string{"--to", "png", dir}, &out, &errw); code != exitOK {
		t.Fatalf("to-png exit = %d\n%s", code, errw.String())
	}
	restored := filepath.Join(dir, "tex.png")
	if topRowIsBlue(t, restored) {
		t.Fatal("round trip did not restore the original orientation")
	}
}

func TestConvertMissingBackendFailsClosed(t *testing.T) {
	dir := t.TempDir()
	writeTestPNG(t, filepath.Join(dir, "tex.png"))
	var out, errw bytes.Buffer
	code := runConvert([]string{"--backend", "/nonexistent/magick", dir}, &out, &errw)
	if code != exitTool {
		t.Fatalf("missing backend exit = %d, want %d", code, exitTool)
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/assets/ -run Convert Expected: FAIL — stub returns exitTool for the round-trip (and the missing-backend case happens to pass by luck; the round-trip is the real failure).

  • Step 3: Implement convert

Remove the runConvert stub from run.go. Create internal/assets/convert.go:

package assets

import (
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

var textureExts = map[string]bool{".dds": true, ".png": true, ".tga": true}

// runConvert converts textures in place, flipping vertically exactly once per
// conversion. --to defaults to dds. Files already in the target format are
// skipped.
func runConvert(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("convert", flag.ContinueOnError)
	fs.SetOutput(stderr)
	to := fs.String("to", "dds", "target format: dds|png|tga")
	backend := fs.String("backend", "magick", "ImageMagick-compatible binary")
	nonRecursive := fs.Bool("non-recursive", false, "do not descend into subdirectories")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}
	target := strings.ToLower(*to)
	if target != "dds" && target != "png" && target != "tga" {
		fmt.Fprintln(stderr, "assets convert: --to must be dds, png, or tga")
		return exitUsage
	}
	dirs := fs.Args()
	if len(dirs) == 0 {
		fmt.Fprintln(stderr, "assets convert: usage: convert [--to dds|png|tga] <dir>...")
		return exitUsage
	}

	magick := look(*backend)
	if magick == "" {
		fmt.Fprintf(stderr, "assets convert: backend %q not found — install ImageMagick (magick)\n", *backend)
		return exitTool
	}

	files, err := walk(dirs, textureExts, !*nonRecursive)
	if err != nil {
		fmt.Fprintln(stderr, "assets convert:", err)
		return exitUsage
	}

	targetExt := "." + target
	failed := false
	for _, src := range files {
		srcExt := strings.ToLower(filepath.Ext(src))
		if srcExt == targetExt {
			continue // already in the target format
		}
		dst := strings.TrimSuffix(src, filepath.Ext(src)) + targetExt
		var convErr error
		if target == "dds" {
			convErr = pngToDDS(magick, src, dst)
		} else {
			convErr = ddsToPNG(magick, src, dst) // works for any decodable source
		}
		if convErr != nil {
			fmt.Fprintf(stderr, "assets convert: %s: %v\n", src, convErr)
			failed = true
			continue
		}
		if dst != src {
			if err := os.Remove(src); err != nil {
				fmt.Fprintf(stderr, "assets convert: %s: %v\n", src, err)
				failed = true
			}
		}
	}
	if failed {
		return exitFail
	}
	return exitOK
}

// ddsToPNG decodes src to dst (PNG/TGA by dst's extension), flipping vertically
// once. The single flip is the defining NWN behavior.
func ddsToPNG(magickBin, src, dst string) error {
	if out, err := runner("", nil, magickBin, src, "-flip", dst); err != nil {
		return fmt.Errorf("magick: %v: %s", err, strings.TrimSpace(string(out)))
	}
	return nil
}

// pngToDDS encodes src to a DDS at dst, flipping vertically once and choosing
// DXT1 for opaque images, DXT5 for images with alpha.
func pngToDDS(magickBin, src, dst string) error {
	compression := "dxt1"
	if hasAlpha(magickBin, src) {
		compression = "dxt5"
	}
	// ponytail: mipmaps rely on magick's default full chain (NWN wants a
	// pyramid, which magick writes by default). Add `-define dds:mipmaps=N`
	// here if a specific count is ever required.
	out, err := runner("", nil, magickBin, src, "-flip",
		"-define", "dds:compression="+compression, dst)
	if err != nil {
		return fmt.Errorf("magick: %v: %s", err, strings.TrimSpace(string(out)))
	}
	return nil
}

// hasAlpha reports whether src has any non-opaque pixel, via `magick identify`.
// On any probe error it assumes alpha (DXT5), the safe/lossless-alpha default.
func hasAlpha(magickBin, src string) bool {
	out, err := runner("", nil, magickBin, "identify", "-format", "%[opaque]", src)
	if err != nil {
		return true
	}
	return !strings.EqualFold(strings.TrimSpace(string(out)), "true")
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/assets/ -run Convert Expected: PASS (or SKIP if magick is absent — it is present in nix develop).

  • Step 5: Commit
git add internal/assets/convert.go internal/assets/convert_test.go internal/assets/run.go
git commit -m "feat(assets): convert textures to/from NWN DDS with single flip"

Task 8: upscale

Files:

  • Create: internal/assets/upscale.go
  • Test: internal/assets/upscale_test.go
  • Modify: internal/assets/run.go (remove the runUpscale stub)

Interfaces:

  • Consumes: walk, look, runner, ddsToPNG/pngToDDS (Task 7), textureExts, exit constants.
  • Produces: func runUpscale(args []string, stdout, stderr io.Writer) int.

Backend discovery, first found wins: upscayl-bin, upscayl, realesrgan-ncnn-vulkan, waifu2x-ncnn-vulkan; --backend overrides. They share the ncnn-vulkan CLI shape -i IN -o OUT -s SCALE. PNG/TGA upscaled directly; DDS goes dds→png→upscale→dds so the flip stays correct (needs magick too). No backend → fail closed.

  • Step 1: Write the failing test

Create internal/assets/upscale_test.go. It stubs runner so no real upscaler runs — the stub simulates the ncnn CLI by copying input to output:

package assets

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"
)

func TestUpscalePNGUsesBackend(t *testing.T) {
	// A fake backend binary on PATH so look() resolves it.
	binDir := t.TempDir()
	fake := filepath.Join(binDir, "upscayl-bin")
	if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir)

	// Stub runner: emulate `-i in -o out -s scale` by copying in->out.
	orig := runner
	defer func() { runner = orig }()
	var gotScale string
	runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
		var in, out string
		for i := 0; i < len(args)-1; i++ {
			switch args[i] {
			case "-i":
				in = args[i+1]
			case "-o":
				out = args[i+1]
			case "-s":
				gotScale = args[i+1]
			}
		}
		data, err := os.ReadFile(in)
		if err != nil {
			return nil, err
		}
		return nil, os.WriteFile(out, data, 0o644)
	}

	dir := t.TempDir()
	src := filepath.Join(dir, "tex.png")
	if err := os.WriteFile(src, []byte("pngdata"), 0o644); err != nil {
		t.Fatal(err)
	}
	var stdout, stderr bytes.Buffer
	if code := runUpscale([]string{"--scale", "2", dir}, &stdout, &stderr); code != exitOK {
		t.Fatalf("upscale exit = %d\n%s", code, stderr.String())
	}
	if gotScale != "2" {
		t.Fatalf("scale passed to backend = %q, want 2", gotScale)
	}
	if data, _ := os.ReadFile(src); string(data) != "pngdata" {
		t.Fatalf("upscaled file content = %q, want the backend output", data)
	}
}

func TestUpscaleNoBackendFailsClosed(t *testing.T) {
	t.Setenv("PATH", t.TempDir()) // empty PATH: no backend resolvable
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "tex.png"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	var stdout, stderr bytes.Buffer
	if code := runUpscale([]string{dir}, &stdout, &stderr); code != exitTool {
		t.Fatalf("no-backend exit = %d, want %d", code, exitTool)
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/assets/ -run Upscale Expected: FAIL — stub returns exitTool, so the first test's exitOK expectation fails.

  • Step 3: Implement upscale

Remove the runUpscale stub from run.go. Create internal/assets/upscale.go:

package assets

import (
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
)

var upscaleBackends = []string{"upscayl-bin", "upscayl", "realesrgan-ncnn-vulkan", "waifu2x-ncnn-vulkan"}

// runUpscale upscales textures in place through an installed ncnn-vulkan
// backend. DDS inputs are bridged through PNG so the NWN flip stays correct.
func runUpscale(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("upscale", flag.ContinueOnError)
	fs.SetOutput(stderr)
	scale := fs.Int("scale", 4, "upscale factor")
	backend := fs.String("backend", "", "override the upscaler 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 upscale: usage: upscale [--scale N] <dir>...")
		return exitUsage
	}

	candidates := upscaleBackends
	if *backend != "" {
		candidates = []string{*backend}
	}
	tool := look(candidates...)
	if tool == "" {
		fmt.Fprintf(stderr, "assets upscale: no upscaler found — install one of: %s\n", strings.Join(upscaleBackends, ", "))
		return exitTool
	}

	files, err := walk(dirs, textureExts, !*nonRecursive)
	if err != nil {
		fmt.Fprintln(stderr, "assets upscale:", err)
		return exitUsage
	}

	// magick is only needed if a .dds input is present; resolve lazily.
	magick := ""
	failed := false
	for _, src := range files {
		var upErr error
		if strings.EqualFold(filepath.Ext(src), ".dds") {
			if magick == "" {
				if magick = look("magick"); magick == "" {
					fmt.Fprintln(stderr, "assets upscale: .dds input needs ImageMagick (magick) for the png bridge")
					return exitTool
				}
			}
			upErr = upscaleDDS(tool, magick, src, *scale)
		} else {
			upErr = upscaleImage(tool, src, src, *scale)
		}
		if upErr != nil {
			fmt.Fprintf(stderr, "assets upscale: %s: %v\n", src, upErr)
			failed = true
		}
	}
	if failed {
		return exitFail
	}
	return exitOK
}

// upscaleImage runs the ncnn-vulkan backend to upscale src into dst (may be the
// same path).
func upscaleImage(tool, src, dst string, scale int) error {
	tmp := dst + ".upscaled.png"
	out, err := runner("", nil, tool, "-i", src, "-o", tmp, "-s", strconv.Itoa(scale))
	if err != nil {
		return fmt.Errorf("%s: %v: %s", filepath.Base(tool), err, strings.TrimSpace(string(out)))
	}
	return os.Rename(tmp, dst)
}

// upscaleDDS bridges a DDS through PNG: dds->png (flip), upscale, png->dds
// (flip back), yielding a correctly-flipped upscaled DDS.
func upscaleDDS(tool, magick, src string, scale int) error {
	tmpPNG := src + ".bridge.png"
	if err := ddsToPNG(magick, src, tmpPNG); err != nil {
		return err
	}
	defer os.Remove(tmpPNG)
	if err := upscaleImage(tool, tmpPNG, tmpPNG, scale); err != nil {
		return err
	}
	return pngToDDS(magick, tmpPNG, src)
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/assets/ -run Upscale Expected: PASS.

  • Step 5: Commit
git add internal/assets/upscale.go internal/assets/upscale_test.go internal/assets/run.go
git commit -m "feat(assets): upscale via ncnn-vulkan backend with dds bridge"

Task 9: compile

Files:

  • Create: internal/assets/compile.go
  • Test: internal/assets/compile_test.go
  • Modify: internal/assets/run.go (remove the runCompile stub)

Interfaces:

  • Consumes: walk, look, runner, mdlExt, internal/assets/mdl (IsASCII, CheckNames), exit constants, getenv.
  • Produces: func runCompile(args []string, stdout, stderr io.Writer, getenv func(string) string) int.

Recursively find ASCII .mdl under each <dir> and compile each to binary in place, one at a time (the engine's development/ and modelcompiler/ folders are flat, single-slot). Discovers the nwmain binary on standard install roots; --nwn overrides. Wraps with xvfb-run when there is no DISPLAY. Aborts a model whose internal names differ (tells the user to run fix-mdl) — does not auto-fix. Binary models are reported and skipped, not errors.

  • Step 1: Write the failing test

Create internal/assets/compile_test.go. It stubs runner to emulate the engine: on compilemodel <stem> it writes a binary artifact into the modelcompiler/ dir. Discovery is satisfied via --nwn pointing at a fake binary:

package assets

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"
)

func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
	home := t.TempDir()
	userData := filepath.Join(home, ".local", "share", "Neverwinter Nights")
	dev := filepath.Join(userData, "development")
	mc := filepath.Join(userData, "modelcompiler")
	for _, d := range []string{dev, mc} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	// Fake nwmain binary for discovery via --nwn.
	binDir := t.TempDir()
	nwmain := filepath.Join(binDir, "nwmain-linux")
	if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
		t.Fatal(err)
	}

	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 ""
	}

	// Stub the engine: writes a binary compiled model into modelcompiler/.
	orig := runner
	defer func() { runner = orig }()
	runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
		// args: compilemodel <stem>
		stem := args[len(args)-1]
		compiled := filepath.Join(mc, stem+".mdl")
		return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
	}

	// Source tree with one ASCII model whose names already match its stem.
	srcDir := t.TempDir()
	src := filepath.Join(srcDir, "foo.mdl")
	body := "newmodel foo\nbeginmodelgeom foo\n  node dummy foo\n    parent null\n  endnode\nendmodelgeom foo\ndonemodel foo\n"
	if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}

	var stdout, stderr bytes.Buffer
	code := runCompile([]string{"--nwn", nwmain, srcDir}, &stdout, &stderr, getenv)
	if code != exitOK {
		t.Fatalf("compile exit = %d\n%s", code, stderr.String())
	}
	// The source is now binary (the compiled artifact moved back over it).
	data, err := os.ReadFile(src)
	if err != nil {
		t.Fatalf("compiled model missing: %v", err)
	}
	if string(data) != "\x00\x00compiled" {
		t.Fatalf("source not replaced by compiled binary: %q", data)
	}
}

func TestCompileAbortsOnNameMismatch(t *testing.T) {
	home := t.TempDir()
	if err := os.MkdirAll(filepath.Join(home, ".local", "share", "Neverwinter Nights", "development"), 0o755); err != nil {
		t.Fatal(err)
	}
	binDir := t.TempDir()
	nwmain := filepath.Join(binDir, "nwmain-linux")
	if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	getenv := func(k string) string {
		if k == "HOME" {
			return home
		}
		if k == "DISPLAY" {
			return ":0"
		}
		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()
	// Internal name "wrong" != stem "foo": must abort before touching the engine.
	if err := os.WriteFile(filepath.Join(srcDir, "foo.mdl"),
		[]byte("newmodel wrong\ndonemodel wrong\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	var stdout, stderr bytes.Buffer
	if code := runCompile([]string{"--nwn", nwmain, srcDir}, &stdout, &stderr, getenv); code != exitFail {
		t.Fatalf("mismatch exit = %d, want %d", code, exitFail)
	}
	if engineCalled {
		t.Fatal("engine should not run for a name-mismatched model")
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./internal/assets/ -run Compile Expected: FAIL — stub returns exitTool.

  • Step 3: Implement compile

Remove the runCompile stub from run.go. Create internal/assets/compile.go:

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] <dir>...")
		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 <install> "+
			"(Steam/GOG/Beamdog install root or the nwmain-linux binary)")
		return exitTool
	}
	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"}
		}
	}

	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)
	}
}

The mdl package needs a stem accessor that is not path-suffixed. Add to internal/assets/mdl/mdl.go:

// ExpectedNameStem is an alias of ExpectedName kept for call-site clarity where
// the value is used as the engine's model stem argument.
func ExpectedNameStem(path string) string { return ExpectedName(path) }

(Alternatively call mdl.ExpectedName(src) directly in compileOne and skip this alias — do whichever reads cleaner. If you skip the alias, replace mdl.ExpectedNameStem(src) with mdl.ExpectedName(src).)

  • Step 4: Run tests to verify they pass

Run: go test ./internal/assets/ -run Compile Expected: PASS.

  • Step 5: Run the full package test + vet

Run: go test ./internal/assets/... && go vet ./internal/assets/... Expected: PASS, no vet complaints. Confirm every temporary stub has been deleted from run.go (grep for exitTool } one-liners).

  • Step 6: Commit
git add internal/assets/compile.go internal/assets/compile_test.go internal/assets/mdl/mdl.go internal/assets/run.go
git commit -m "feat(assets): compile ASCII mdl via the NWN engine in place"

Task 10: Full-suite verification

Files: none (verification only).

  • Step 1: Run the whole test suite

Run: make check Expected: go vet ./..., go test ./..., shellcheck scripts/*.sh, and yamllint .gitea all pass.

  • Step 2: Build every binary and smoke-test

Run: make smoke Expected: ends with smoke ok: 0 unwired fail closed (70), 6 wired delegate to nwn-tool logic.

  • Step 3: Exercise the real surface once by hand

Run:

go run ./cmd/crucible assets --help
go run ./cmd/crucible assets check-dupes .

Expected: --help prints the seven-subcommand help and exits 0; check-dupes . prints either check-dupes: OK (exit 0) or collision lines (exit 1) — never the unwired exit-70 message.

  • Step 4: Commit any doc touch-ups (only if Step 3 surfaced a wording fix)
git add -A
git commit -m "docs(assets): tidy help text"

Task 11 (deferred, separate repo): sow-assets-manifest consumer migration

This task lands as its own commit in ../sow-assets-manifest after a crucible release that includes assets is available — it is NOT part of this repo's commits. Listed here so it is not lost. Do it only once the release exists and ./crucible.sh in that repo resolves the new version.

  • Delete the three fully-folded driver scripts: scripts/check-ascii-mdl.sh, scripts/fix-mdl-model-names.sh, scripts/clean-duplicate-names.sh.
  • Repoint the Makefile targets to the wrapper:
    • check-mdl./crucible.sh assets check-mdl …
    • fix-mdl./crucible.sh assets fix-mdl [--dry-run] …
    • check-dupes directory scan → ./crucible.sh assets check-dupes … (the check-duplicate-names.sh --manifests line stays as-is)
    • clean-dupes./crucible.sh assets clean-dupes [--dry-run] <PRIMARY> <CLEAN>
  • Keep for now (retired in the depot-pipeline follow-up spec): scripts/check-duplicate-names.sh (only its --manifests mode), scripts/mdl-scan.awk, scripts/mdl-name-lib.sh (still sourced by import.sh's batched pre-import gate).

Self-Review

Spec coverage — every design section maps to a task:

  • Seven commands → Tasks 49 (check-dupes/clean-dupes/check-mdl/fix-mdl/convert/upscale/compile).
  • internal/assets/run.go + shared helpers → Task 2.
  • internal/assets/mdl module → Task 1.
  • cmd/crucible-assets/main.go + dispatch Registry + delegate branch → Task 3.
  • docs/command-surface.md rows + smoke assets expectation → Task 3.
  • Consumer migration → Task 11 (deferred, separate repo, as the spec dictates).
  • Error handling (missing tool 70, per-file failures collected, report commands exit-on-find, usage 64) → encoded in each command's exit contract + Global Constraints.
  • Testing section (flag/usage parsing, backend discovery on fabricated PATH, walk recursion, real magick round-trip asserting single flip, mdl fixtures, dupes trees + overlap guard + dry-run) → Tasks 1, 2, 4, 7, 8.

Out of scope (correctly deferred, not planned here): the check-duplicate-names.sh --manifests mode and the depot-interacting pipeline (import.sh/sync-assets.sh/export.sh) — both belong to 2026-07-12-crucible-depot-pipeline-migration-design.md.

Type consistency: runner(dir, env, name, args...), walk(roots, exts, recursive), look(candidates...), ddsToPNG/pngToDDS(magickBin, src, dst), and mdl.{IsASCII,ExpectedName,CheckNames,FixNames} are used with identical signatures across every task that consumes them. Exit constants (exitOK/exitFail/exitUsage/exitTool) are defined once in Task 2 and reused.

Simplifications deliberately taken (per spec "Simplify" + ponytail, each marked in code): convert relies on magick's default DDS mipmap chain rather than a tuned count; FixNames rebuilds only changed lines with single spaces (matching the retiring awk); fix-mdl reads each file once rather than porting the awk batch. Each has a ponytail: comment naming the ceiling and upgrade path.