feat(assets): seven asset commands + Run dispatch/helpers

check-dupes, clean-dupes, check-mdl, fix-mdl, convert, upscale, compile
behind a single runner indirection, mirroring internal/depot's Run shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:13:54 +02:00
co-authored by Claude Opus 4.8
parent d8f01dc567
commit dd01c96473
16 changed files with 1400 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
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
}
+32
View File
@@ -0,0 +1,32 @@
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())
}
}
+190
View File
@@ -0,0 +1,190 @@
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)
}
}
+110
View File
@@ -0,0 +1,110 @@
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")
}
}
+116
View File
@@ -0,0 +1,116 @@
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")
}
+108
View File
@@ -0,0 +1,108 @@
package assets
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"os/exec"
"path/filepath"
"testing"
)
// writeTestPNG writes a 16x16 image: top half red, bottom half blue. The size
// and the half-way split keep every 4x4 DXT block a single flat color, so the
// DXT1 round trip stays lossless and only the flip is under test. (A 4x4 image
// is one mixed DXT block that magick's encoder collapses to a single color.)
func writeTestPNG(t *testing.T, path string) {
t.Helper()
const n = 16
img := image.NewRGBA(image.Rect(0, 0, n, n))
for y := 0; y < n; y++ {
c := color.RGBA{255, 0, 0, 255} // red
if y >= n/2 {
c = color.RGBA{0, 0, 255, 255} // blue
}
for x := 0; x < n; 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)
}
}
+151
View File
@@ -0,0 +1,151 @@
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)
}
+80
View File
@@ -0,0 +1,80 @@
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)
}
}
+103
View File
@@ -0,0 +1,103 @@
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
}
+48
View File
@@ -0,0 +1,48 @@
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)
}
}
+84
View File
@@ -0,0 +1,84 @@
// 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 ""
}
+63
View File
@@ -0,0 +1,63 @@
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)
}
}
+55
View File
@@ -0,0 +1,55 @@
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>
`)
}
+26
View File
@@ -0,0 +1,26 @@
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)
}
}
+98
View File
@@ -0,0 +1,98 @@
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)
}
+69
View File
@@ -0,0 +1,69 @@
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)
}
}