Fold check-ascii-mdl, fix-mdl-model-names, check-duplicate-names, and clean-duplicate-names out of sow-assets-manifest into `crucible assets` (seven commands total), via a shared internal/assets/mdl port of mdl-name-lib.sh + mdl-scan.awk. Consumer keeps the awk libs for import.sh's hot-path gate; moving import.sh into `crucible depot` is a decomposed follow-up spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
Crucible assets builder — design
Date: 2026-07-12 Status: approved, pre-implementation
Goal
Fold the NWN:EE asset tools out of two standalone toolkits into Crucible as a
new self-contained builder, crucible assets:
- from the Python toolkit (
nwnee-asset-processing) — model compile, texture convert, texture upscale. Those scripts are references only; broken as-is and carrying a heavy config/staging/report layer that we drop. - from
sow-assets-manifest/scripts/— the model-name and duplicate-name integrity tools (check-ascii-mdl.sh,fix-mdl-model-names.sh,check-duplicate-names.sh,clean-duplicate-names.sh). These are ported into Go and the shell drivers removed;sow-assets-manifestcalls./crucible.sh assets …through its bootstrap wrapper instead.
Seven commands:
crucible assets compile— compile every ASCII.mdlin a directory to binary, in place.crucible assets convert— convert every texture in a directory to NWN:EE DDS (or back to PNG/TGA), flipping vertically every time so a DDS is always upside-down relative to its source.crucible assets upscale— upscale every texture in a directory through whatever upscaling backend is installed.crucible assets check-mdl— report uncompiled ASCII.mdlfiles and model-name mismatches (read-only).crucible assets fix-mdl— lowercase.mdlfilenames and rewrite ASCII model/root names to match each file's stem.crucible assets check-dupes— report runtime-name (basename) collisions in directories, or hak-scoped collisions acrossassets/*.ymlmanifests.crucible assets clean-dupes— delete files from a "clean" tree whose basename collides with anything in a "primary" tree.
Principles
- No configuration files. Arguments only. No
processing/staging dirs, no discards, no YAML, no reports. Operates in place on the target directory. - Discover, don't configure. External tools (the NWN engine, ImageMagick, an
upscaler) are found on
PATHand at standard install locations. Each has a single override flag when discovery is not enough. - 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.
- Simplify. Drop the reference tools' padding / power-of-two /
nwn-safe/ explicit-format knobs (convert auto-picks DXT1 vs DXT5) and the small-texture staging (upscale). These can return later if wanted.
Architecture
A new self-contained builder that follows the existing depot pattern exactly
(delegates straight to its own internal package, bypassing the legacy
internal/app surface):
internal/assets/run.go—func Run(args []string, stdout, stderr io.Writer, getenv func(string) string) int. Parses the subcommand (compile|convert|upscale|check-mdl|fix-mdl|check-dupes|clean-dupes) and dispatches. Returns a sysexits-style code.internal/assets/— one file per command plus small shared helpers (recursion/file-selection, external-command runner, backend discovery).cmd/crucible-assets/main.go— one-line shim:func main() { os.Exit(dispatch.RunBuilder("assets", os.Args[1:])) }.internal/dispatch:- one
Registryentry forassets(Wired: true) listing the seven commands withUsage/Options; - extend the direct-delegate branch that today reads
if b.Name == "depot" && b.Wiredsoassetsalso routes toassets.Run(...)instead of the legacyapp.Run.
- one
docs/command-surface.md— seven visible-command rows.wrappers/consumers.txt/ wrappers are unchanged (no new consumer; the consumers listed already vendor the wrapper).
Shared helpers (internal/assets)
walk(roots, exts)— collect files under each root (recursive by default) whose extension is inexts, case-insensitively. Reject nothing fancy; these are arguments the caller chose.run(cmd, args...)— thinexec.Commandwrapper capturing combined output, returning(output, error). A single indirection point so tests can observe invocations. cwd and env overrides passed as needed.look(names...)— return the first name found viaexec.LookPath, or the first path that exists from a supplied list of candidates. Used by every backend discovery.
Shared MDL name module (internal/assets/mdl)
A pure-Go port of mdl-name-lib.sh + mdl-scan.awk, no external tools. One
place three commands share (compile's pre-check, check-mdl, fix-mdl):
IsASCII(path)— NUL in the first 256 bytes → binary; else the leading keyword must be#/newmodel/node/setsupermodel. Mirrorsmdl_is_ascii.ExpectedName(path)— the file stem (sans.mdl).CheckNames(path) []Mismatch— for an ASCII model, report header-token mismatches (newmodel,setsupermodel,beginmodelgeom,endmodelgeom,donemodel,newanim,doneanim— case-insensitive vs the stem) and the geometry base node mismatch (the node insidebeginmodelgeomwhose parent isnull; empty if none). Mirrorsmdl_check_model_name+mdl_base_name.FixNames(in) (out []byte, changed bool)— rewrite only the model identity: the header tokens above, plus the base node's declaration / anyparent/animrootpointing at the old base name → the expected name. Animation bone names and supermodel animroots are left untouched so inheritance keeps working. Mirrorsmdl_fix_model_namebyte-for-byte in intent.
Detection must stay identical to the awk classifier so behavior does not drift
while import.sh still runs the awk version (see Consumer migration).
crucible assets compile <dir>…
Usage: crucible assets compile [--nwn <install>] [--non-recursive] <dir>…
Recursively find ASCII .mdl files under each <dir> and compile each to a
binary .mdl in place (the compiled result replaces the source).
The NWN:EE engine is the only real ASCII→binary MDL compiler, so this drives it exactly as the reference does:
- Discovery. User data dir is fixed on Linux at
~/.local/share/Neverwinter Nights(holds the flatdevelopment/andmodelcompiler/folders the engine uses). Thenwmainbinary is found by probing standard install roots, first hit wins:~/.local/share/Steam/steamapps/common/Neverwinter Nights/bin/linux-x86/nwmain-linux- GOG:
~/GOG Games/Neverwinter Nights Enhanced Edition/.../nwmain-linux - Beamdog install dirs.
--nwn <install>overrides (path to the install root or directly to the binary).
- Headless. The engine is a GUI binary. If there is no
DISPLAYandxvfb-runis present, wrap the call:xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>. - Per model (one at a time — the engine's
development/andmodelcompiler/folders are flat and single-slot):- Skip if the file is not ASCII (binary/compiled
.mdl) — reported, not an error. - Copy source into
development/<name>. - Run
nwmain-linux compilemodel <stem>with cwd = the binary dir. - Collect the compiled artifact from
modelcompiler/matched case-insensitively by stem. - Move it back over the source path, lowercased.
- Clean the temp files created in
development/andmodelcompiler/.
- Skip if the file is not ASCII (binary/compiled
- Collisions. A pre-existing
development/<name>ormodelcompiler/<stem>aborts that model before any mutation.
Exit: non-zero if any model fails; each failure prints a short excerpt of the
engine log (~/.local/share/Neverwinter Nights/logs/nwengineLog.txt) as
advisory diagnostics.
Dropped vs reference: internal-name-mismatch pre-checks and the
Model Names Differ fail rule are kept (cheap, prevents silent wrong output),
using the shared internal/assets/mdl module — compile aborts a model whose
names differ and tells the user to run crucible assets fix-mdl. It does not
auto-fix (kept separate, by decision). The discard-manifest, dry-run/report
layer, and processing/in|out staging are dropped.
crucible assets convert <dir>…
Usage: crucible assets convert [--to dds|png|tga] [--backend <path>] [--non-recursive] <dir>…
Recursively convert textures in place. --to defaults to dds. The source set
is every texture under the roots whose format is not already the target, among
{.dds, .png, .tga}.
Every conversion flips vertically exactly once. Converting to DDS produces an upside-down DDS (NWN's convention); converting a DDS back to PNG/TGA flips it upright again. The flip is unconditional — it is the defining behavior, not a policy.
Backend: ImageMagick (magick), one tool for decode + flip + encode across all
three formats:
- to DDS:
magick <src> -flip -define dds:mipmaps=<n> -define dds:compression=<dxt1|dxt5> <dst>.dds, choosing DXT1 when the source is opaque and DXT5 when it has alpha (detected withmagick identify). - from DDS:
magick <src>.dds -flip <dst>.<png|tga>. --backend <path>points at a different ImageMagick-compatible binary or a dedicated encoder for higher-quality DXT (e.g. compressonator); default ismagick.
The original file is replaced in place — canonical DDS is the point. Exit non-zero if any file fails.
Dropped vs reference: --pad-color, POT/legacy-safe/nwn-safe/--strict
sizing, explicit --format, alpha-detect toggles, discard staging. Auto DXT
selection replaces the format knobs.
crucible assets upscale <dir>…
Usage: crucible assets upscale [--scale N] [--backend <path>] [--non-recursive] <dir>…
Recursively upscale textures in place.
- Backend discovery, first found wins:
upscayl-bin,upscayl,realesrgan-ncnn-vulkan,waifu2x-ncnn-vulkan. These share a compatible ncnn-vulkan CLI shape (-i <in> -o <out> -s <scale>).--backend <path>overrides. --scale Ndefault 4.- Backends operate on PNG; real NWN textures are DDS, so for a
.ddsinput the command reuses the convert helpers: dds→png (flip), run the backend, png→dds (flip back) — yielding a correctly-flipped upscaled DDS. PNG/TGA inputs are upscaled directly in place. - No backend found → fail closed with a message naming the supported backends.
Dropped vs reference: min-dimension small-texture staging, the separate
--dds-backend plumbing (it reuses convert), configured backend-path table.
crucible assets check-mdl <path>…
Port of check-ascii-mdl.sh. Read-only. Each <path> is a file or a directory
(recursed for *.mdl). For every model, using internal/assets/mdl:
- report an uncompiled ASCII
.mdl(a binary/compiled one is fine, skipped); - report every model-name mismatch (header tokens and geometry base node) with file, line, the offending token, and the expected stem.
Exit non-zero if any ASCII model or mismatch is found; zero and an OK line
otherwise. No mutation.
crucible assets fix-mdl [--dry-run] <path>…
Port of fix-mdl-model-names.sh. For each *.mdl under the paths:
- Lowercase the filename if it is not already lowercase (
mv; abort that file on a case-collision with an existing target). - For ASCII models with a name mismatch, rewrite the model identity in place
via
mdl.FixNames(binary models are skipped — the engine owns those).
--dry-run reports every "would lowercase" / "would fix" without touching disk.
Prints a per-file log and a final no broken mdl model names found when clean.
Selection optimization from the reference is preserved: a file is a fix candidate only if it needs a content fix (name mismatch) or a filename lowercase, so the expensive per-file read/rewrite runs only on the few that matter, not the whole tree.
crucible assets check-dupes [--manifests] <dir>…
Port of check-duplicate-names.sh. Read-only. NWN packs hak entries by
basename, not path, so files at different paths with the same basename
(case-insensitively) silently clobber on pack. This flags them.
- Directory mode (default): across all
<dir>args, report any two files whose lowercased basename matches. --manifests: additionally read everyassets/*.ymlin the current repo and report basename collisions within the same hak (.assets[].pathgrouped by.assets[].hak), reusing Crucible's existinggopkg.in/yaml.v3parsing. Requires no externalyq.
Exit non-zero if any collision is found.
crucible assets clean-dupes [--dry-run] <primary> <clean>
Port of clean-duplicate-names.sh. Deletes files from the <clean> tree whose
basename collides (case-insensitively) with any file in the <primary> tree.
Mutates only <clean>. Same rule as check-dupes directory mode, but resolves
the collision by removal instead of reporting.
- Refuses to run if
<primary>and<clean>overlap (either contains the other), matching the script's guard. --dry-runreports every "would delete" without removing.
Exit zero on success with a count of removed (or would-remove) files.
Consumer migration (sow-assets-manifest)
sow-assets-manifest already ships the crucible bootstrap wrapper
(crucible.sh) and uses crucible depot from its Makefile, so it resolves a
released crucible with zero extra setup.
- Delete the four folded driver scripts:
scripts/check-ascii-mdl.sh,scripts/fix-mdl-model-names.sh,scripts/check-duplicate-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→./crucible.sh assets check-dupes …and./crucible.sh assets check-dupes --manifestsclean-dupes→./crucible.sh assets clean-dupes [--dry-run] <PRIMARY> <CLEAN>- the
hakstarget'scheck-duplicate-names.sh --manifestsguard →./crucible.sh assets check-dupes --manifests
- Keep for now:
scripts/mdl-scan.awkandscripts/mdl-name-lib.sh. They are still sourced byimport.shfor its batched pre-import gate (~28× faster than per-file, on a hot path). They retire in the follow-up below. Because both the awk classifier and the Gomdlmodule must agree, the port keeps detection byte-for-byte identical (a shared fixture set checks this).
Nothing in sow-assets-manifest is committed by this project's plan except the
script deletions and Makefile edits; that repo's change lands as its own commit
once the new crucible release with assets is available.
Out of scope — follow-up spec
Moving the depot-interacting pipeline (import.sh, sync-assets.sh,
export.sh, and the inline mdl gate) into crucible depot is a separate
project with its own spec. When import.sh becomes crucible depot import, its
gate uses the in-process internal/assets/mdl module and
scripts/mdl-scan.awk + scripts/mdl-name-lib.sh are deleted. This spec
deliberately stops at the standalone integrity tools to keep one focused plan.
Error handling
- Missing external tool (engine,
magick, upscaler) → non-zero exit, message names the tool and how to get it. Never a faked artifact. The pure-Go integrity commands (check-mdl,fix-mdl,check-dupes,clean-dupes) need no external tool. - Per-file failures are collected and printed as a summary line at the end; the command exits non-zero if any file failed but still processes the rest.
- Report commands (
check-mdl,check-dupes) exit non-zero when they find problems — that is their contract as CI/pre-import gates, not a tool error. - Unknown subcommand / bad flags → usage error, exit 64.
Testing
Go tests in internal/assets:
- flag/subcommand parsing and usage errors;
- backend discovery against a fabricated
PATH(temp dir with stub executables) — asserts first-match ordering and the--backendoverride; walkrecursion and extension filtering, including--non-recursive;- one real convert round-trip using
magick(present in the dev shell): encode a small PNG to DDS and back, asserting the pixels are vertically flipped after a single conversion and restored after the round trip. internal/assets/mdl: table-driven fixtures — ASCII vs binary detection, each header-token mismatch, the base-node mismatch, andFixNamesproducing a model that then passesCheckNames. A parity fixture set shared in intent withmdl-scan.awkso the Go port and the still-present awk agree.check-dupes/clean-dupes: temp trees asserting basename-collision detection (case-insensitive), the--manifestshak-scoped path against a smallassets/*.ymlfixture, the overlap guard, and--dry-runmutating nothing.
External engine/upscaler calls are exercised through the run indirection with
a stub in tests; they are not invoked for real in CI.
make check must stay green; add an assets expectation to make smoke for
the wired exit.