diff --git a/internal/assets/checkmdl.go b/internal/assets/checkmdl.go new file mode 100644 index 0000000..7f8f4c8 --- /dev/null +++ b/internal/assets/checkmdl.go @@ -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 ...") + 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 +} diff --git a/internal/assets/checkmdl_test.go b/internal/assets/checkmdl_test.go new file mode 100644 index 0000000..1c09684 --- /dev/null +++ b/internal/assets/checkmdl_test.go @@ -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()) + } +} diff --git a/internal/assets/compile.go b/internal/assets/compile.go new file mode 100644 index 0000000..dca73a2 --- /dev/null +++ b/internal/assets/compile.go @@ -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] ...") + 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) + } +} diff --git a/internal/assets/compile_test.go b/internal/assets/compile_test.go new file mode 100644 index 0000000..5af0ce6 --- /dev/null +++ b/internal/assets/compile_test.go @@ -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 := 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") + } +} diff --git a/internal/assets/convert.go b/internal/assets/convert.go new file mode 100644 index 0000000..3eae677 --- /dev/null +++ b/internal/assets/convert.go @@ -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] ...") + 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") +} diff --git a/internal/assets/convert_test.go b/internal/assets/convert_test.go new file mode 100644 index 0000000..64d5c23 --- /dev/null +++ b/internal/assets/convert_test.go @@ -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) + } +} diff --git a/internal/assets/dupes.go b/internal/assets/dupes.go new file mode 100644 index 0000000..0e279a9 --- /dev/null +++ b/internal/assets/dupes.go @@ -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 ...") + 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) +} diff --git a/internal/assets/dupes_test.go b/internal/assets/dupes_test.go new file mode 100644 index 0000000..832d836 --- /dev/null +++ b/internal/assets/dupes_test.go @@ -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) + } +} diff --git a/internal/assets/fixmdl.go b/internal/assets/fixmdl.go new file mode 100644 index 0000000..113ba43 --- /dev/null +++ b/internal/assets/fixmdl.go @@ -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] ...") + 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 +} diff --git a/internal/assets/fixmdl_test.go b/internal/assets/fixmdl_test.go new file mode 100644 index 0000000..b8c0636 --- /dev/null +++ b/internal/assets/fixmdl_test.go @@ -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) + } +} diff --git a/internal/assets/helpers.go b/internal/assets/helpers.go new file mode 100644 index 0000000..23ad465 --- /dev/null +++ b/internal/assets/helpers.go @@ -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 "" +} diff --git a/internal/assets/helpers_test.go b/internal/assets/helpers_test.go new file mode 100644 index 0000000..6dc8809 --- /dev/null +++ b/internal/assets/helpers_test.go @@ -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) + } +} diff --git a/internal/assets/run.go b/internal/assets/run.go new file mode 100644 index 0000000..d21fdaf --- /dev/null +++ b/internal/assets/run.go @@ -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] ... + 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] +`) +} diff --git a/internal/assets/run_test.go b/internal/assets/run_test.go new file mode 100644 index 0000000..05b16fd --- /dev/null +++ b/internal/assets/run_test.go @@ -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) + } +} diff --git a/internal/assets/upscale.go b/internal/assets/upscale.go new file mode 100644 index 0000000..51b5a50 --- /dev/null +++ b/internal/assets/upscale.go @@ -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] ...") + 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) +} diff --git a/internal/assets/upscale_test.go b/internal/assets/upscale_test.go new file mode 100644 index 0000000..b6f172e --- /dev/null +++ b/internal/assets/upscale_test.go @@ -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) + } +}