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

2412 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.go``Run` dispatch, usage, exit constants.
- `internal/assets/helpers.go``walk`, `look`, `runner`.
- `internal/assets/dupes.go``check-dupes` + `clean-dupes`.
- `internal/assets/checkmdl.go``check-mdl`.
- `internal/assets/fixmdl.go``fix-mdl`.
- `internal/assets/convert.go``convert` + shared `ddsToPNG`/`pngToDDS` helpers.
- `internal/assets/upscale.go``upscale`.
- `internal/assets/compile.go``compile`.
- `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`:
```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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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:
```go
// --- 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**
```bash
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):
```go
{
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:
```go
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets"
```
Replace the depot special-case block in `runBuilder`:
```go
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:
```go
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`:
```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:
```go
"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`:
```go
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:
```bash
wired=(depot hak module topdata wiki)
```
to:
```bash
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:
```markdown
| `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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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:
```go
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`:
```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**
```bash
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:
```go
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`:
```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**
```bash
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:
```go
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`:
```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`:
```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**
```bash
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:
```bash
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)
```bash
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.