# 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] ... assets convert [--to dds|png|tga] [--backend PATH] [--non-recursive] ... assets upscale [--scale N] [--backend PATH] [--non-recursive] ... assets check-mdl ... assets fix-mdl [--dry-run] ... assets check-dupes ... assets clean-dupes [--dry-run] `) } ``` 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] ..."}, {Name: "convert", Summary: "convert textures to/from NWN DDS (flips vertically)", Usage: "crucible assets convert [--to dds|png|tga] [--backend PATH] [--non-recursive] ..."}, {Name: "upscale", Summary: "upscale textures through an installed backend", Usage: "crucible assets upscale [--scale N] [--backend PATH] [--non-recursive] ..."}, {Name: "check-mdl", Summary: "report uncompiled ASCII .mdl and model-name mismatches", Usage: "crucible assets check-mdl ..."}, {Name: "fix-mdl", Summary: "lowercase .mdl names and rewrite model identity to match", Usage: "crucible assets fix-mdl [--dry-run] ..."}, {Name: "check-dupes", Summary: "report runtime-name (basename) collisions across dirs", Usage: "crucible assets check-dupes ..."}, {Name: "clean-dupes", Summary: "delete clean-tree files whose basename collides with primary", Usage: "crucible assets clean-dupes [--dry-run] "}, }, 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 ...") 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 whose lower-cased basename collides // with any file in . Mutates only . 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] ") 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 `` 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 ...") 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] ...") 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] ...") 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] ...") 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 `` 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 ` 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 := 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] ...") return exitUsage } home := getenv("HOME") userData := filepath.Join(home, ".local", "share", "Neverwinter Nights") dev := filepath.Join(userData, "development") mc := filepath.Join(userData, "modelcompiler") nwmain := findNWMain(*nwn, home) if nwmain == "" { fmt.Fprintln(stderr, "assets compile: nwmain-linux not found — pass --nwn "+ "(Steam/GOG/Beamdog install root or the nwmain-linux binary)") return exitTool } binDir := filepath.Dir(nwmain) // 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] ` - **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 4–9 (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.