package pipeline import ( "bytes" "encoding/json" "errors" "os" "os/exec" "path/filepath" "slices" "strings" "testing" "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) func TestBuildThenExtract(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" } } `) mustMkdir(t, filepath.Join(root, "src", "module")) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" } ] } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } buildResult, err := Build(p) if err != nil { t.Fatalf("build: %v", err) } if buildResult.Resources != 1 { t.Fatalf("expected 1 resource, got %d", buildResult.Resources) } if err := os.Remove(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil { t.Fatalf("remove source file: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("rescan: %v", err) } extractResult, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if extractResult.Written != 1 { t.Fatalf("expected 1 extracted file, got %d", extractResult.Written) } if _, err := os.Stat(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil { t.Fatalf("expected extracted module file: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan after extract: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("rebuild after extract: %v", err) } compareResult, err := Compare(p) if err != nil { t.Fatalf("compare: %v", err) } if compareResult.Checked != 1 { t.Fatalf("expected 1 compared resource, got %d", compareResult.Checked) } } func TestExtractReadsHAKAssets(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } modFile, err := os.Create(filepath.Join(root, "build", "testmod.mod")) if err != nil { t.Fatalf("create mod: %v", err) } if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil { t.Fatalf("write mod: %v", err) } if err := modFile.Close(); err != nil { t.Fatalf("close mod: %v", err) } hakFile, err := os.Create(filepath.Join(root, "build", "vfx.hak")) if err != nil { t.Fatalf("create hak: %v", err) } if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{ {Name: "test_vfx", Type: 0x07D2, Data: []byte("mdl-data")}, {Name: "test_icon", Type: 0x0006, Data: []byte("plt-data")}, })); err != nil { t.Fatalf("write hak: %v", err) } if err := hakFile.Close(); err != nil { t.Fatalf("close hak: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if len(result.HAKPaths) != 1 { t.Fatalf("expected 1 hak path, got %d", len(result.HAKPaths)) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "test_vfx.mdl")); err != nil { t.Fatalf("expected extracted mdl asset: %v", err) } if _, err := os.Stat(filepath.Join(root, "assets", "plt", "test_icon.plt")); err != nil { t.Fatalf("expected extracted plt asset: %v", err) } } func TestExtractConfiguredHAKDiscoveryIgnoresUnconfiguredArchives(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src assets: assets build: build extract: hak_discovery: configured_haks haks: - name: wanted priority: 1 include: - wanted/** `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } modFile, err := os.Create(p.ModuleArchivePath()) if err != nil { t.Fatalf("create mod: %v", err) } if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil { t.Fatalf("write mod: %v", err) } if err := modFile.Close(); err != nil { t.Fatalf("close mod: %v", err) } for _, name := range []string{"wanted", "ignored"} { hakFile, err := os.Create(filepath.Join(root, "build", name+".hak")) if err != nil { t.Fatalf("create hak: %v", err) } if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{ {Name: name + "_asset", Type: 0x07D2, Data: []byte("mdl-data")}, })); err != nil { t.Fatalf("write hak: %v", err) } if err := hakFile.Close(); err != nil { t.Fatalf("close hak: %v", err) } } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if len(result.HAKPaths) != 1 || filepath.Base(result.HAKPaths[0]) != "wanted.hak" { t.Fatalf("expected only configured hak, got %#v", result.HAKPaths) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "wanted_asset.mdl")); err != nil { t.Fatalf("expected wanted asset: %v", err) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "ignored_asset.mdl")); !os.IsNotExist(err) { t.Fatalf("expected ignored asset to remain absent, err=%v", err) } } func TestExtractDeletesConsumedModuleArchiveAfterSuccessWhenConfigured(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "extract": { "delete_module_archive_after_success": true } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Original Module" } ] } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } buildResult, err := BuildModule(p) if err != nil { t.Fatalf("build module: %v", err) } if err := os.Remove(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil { t.Fatalf("remove source file: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("rescan: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if len(result.DeletedArchivePaths) != 1 { t.Fatalf("expected 1 deleted archive path, got %#v", result.DeletedArchivePaths) } if result.DeletedArchivePaths[0] != buildResult.ModulePath { t.Fatalf("expected deleted archive %s, got %#v", buildResult.ModulePath, result.DeletedArchivePaths) } if _, err := os.Stat(buildResult.ModulePath); !os.IsNotExist(err) { t.Fatalf("expected consumed module archive to be removed, stat err=%v", err) } if _, err := os.Stat(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil { t.Fatalf("expected extracted module file: %v", err) } } func TestExtractKeepsConsumedModuleArchiveWhenExtractionFails(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "extract": { "delete_module_archive_after_success": true } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } modFile, err := os.Create(p.ModuleArchivePath()) if err != nil { t.Fatalf("create mod: %v", err) } if err := erf.Write(modFile, erf.New("MOD ", []erf.Resource{ {Name: "broken", Type: 0xFFFF, Data: []byte("bad")}, })); err != nil { t.Fatalf("write mod: %v", err) } if err := modFile.Close(); err != nil { t.Fatalf("close mod: %v", err) } result, err := Extract(p) if err == nil { t.Fatal("expected extract to fail") } if len(result.DeletedArchivePaths) != 0 { t.Fatalf("expected no deleted archive paths, got %#v", result.DeletedArchivePaths) } if _, statErr := os.Stat(p.ModuleArchivePath()); statErr != nil { t.Fatalf("expected consumed module archive to remain after failed extract, stat err=%v", statErr) } } func TestBuildSplitsConfiguredHAKs(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 300, "split": true, "include": ["core/**"] } ] } `) mustMkdir(t, filepath.Join(root, "src", "module")) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), strings.Repeat("a", 80)) mustWriteFile(t, filepath.Join(root, "assets", "core", "b.tga"), strings.Repeat("b", 80)) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.HAKPaths) != 2 { t.Fatalf("expected 2 hak outputs, got %d", len(result.HAKPaths)) } if _, err := os.Stat(filepath.Join(root, "build", "core_01.hak")); err != nil { t.Fatalf("expected first split hak: %v", err) } if _, err := os.Stat(filepath.Join(root, "build", "core_02.hak")); err != nil { t.Fatalf("expected second split hak: %v", err) } rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if len(manifest.HAKs) != 2 { t.Fatalf("expected 2 manifest entries, got %d", len(manifest.HAKs)) } if len(manifest.ModuleHAKs) != 2 || manifest.ModuleHAKs[0] != "core_01" || manifest.ModuleHAKs[1] != "core_02" { t.Fatalf("unexpected module hak order: %#v", manifest.ModuleHAKs) } applyResult, err := ApplyHAKManifest(p, filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("apply hak manifest: %v", err) } if applyResult.HAKCount != 2 { t.Fatalf("expected 2 applied hak entries, got %d", applyResult.HAKCount) } updated, err := os.ReadFile(filepath.Join(root, "src", "module", "module.ifo.json")) if err != nil { t.Fatalf("read updated ifo: %v", err) } if !strings.Contains(string(updated), "\"value\": \"core_01\"") || !strings.Contains(string(updated), "\"value\": \"core_02\"") { t.Fatalf("updated ifo did not contain expected hak entries:\n%s", string(updated)) } } func TestBuildModuleCompilesReferencedScripts(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "src", "placeables")) mustMkdir(t, filepath.Join(root, "src", "scripts")) mustMkdir(t, filepath.Join(root, ".nwn-assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": ".nwn-assets", "build": "build" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" } ] } } `) mustWriteFile(t, filepath.Join(root, "src", "placeables", "thing.utp.json"), `{ "file_type": "UTP ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "OnUsed", "type": "ResRef", "value": "use_thing" } ] } } `) mustWriteFile(t, filepath.Join(root, "src", "scripts", "use_thing.nss"), `void main() {}`) compiler := filepath.Join(root, "fake-compiler.sh") mustWriteFile(t, compiler, `#!/bin/sh set -eu out="" src="" while [ "$#" -gt 0 ]; do case "$1" in -o) out="$2" shift 2 ;; --dirs) shift 2 ;; *) src="$1" shift ;; esac done [ -n "$out" ] [ -n "$src" ] printf 'compiled:%s\nNWN_ROOT=%s\nNWN_HOME=%s\nNWN_USER_DIRECTORY=%s\n' "$(basename "$src")" "${NWN_ROOT-}" "${NWN_HOME-}" "${NWN_USER_DIRECTORY-}" > "$out" `) if err := os.Chmod(compiler, 0o755); err != nil { t.Fatalf("chmod fake compiler: %v", err) } t.Setenv("SOW_NWN_SCRIPT_COMPILER", compiler) installRoot := filepath.Join(root, "nwn-install") mustMkdir(t, filepath.Join(installRoot, "data")) t.Setenv("SOW_NWN_ROOT", installRoot) userDir := filepath.Join(root, "nwn-user") t.Setenv("SOW_NWN_USER_DIRECTORY", userDir) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildModule(p) if err != nil { t.Fatalf("build module: %v", err) } if result.Resources != 4 { t.Fatalf("expected 4 resources, got %d", result.Resources) } fh, err := os.Open(result.ModulePath) if err != nil { t.Fatalf("open built module: %v", err) } defer fh.Close() archive, err := erf.Read(fh) if err != nil { t.Fatalf("read built module: %v", err) } foundSource := false foundCompiled := false for _, resource := range archive.Resources { if resource.Name != "use_thing" { continue } if extension, ok := erf.ExtensionForResourceType(resource.Type); ok { switch extension { case "nss": foundSource = true case "ncs": foundCompiled = true if !strings.Contains(string(resource.Data), "compiled:use_thing.nss") { t.Fatalf("unexpected compiled payload: %q", string(resource.Data)) } if !strings.Contains(string(resource.Data), "NWN_ROOT="+installRoot) { t.Fatalf("compiled payload missing NWN_ROOT: %q", string(resource.Data)) } if !strings.Contains(string(resource.Data), "NWN_HOME="+userDir) { t.Fatalf("compiled payload missing NWN_HOME: %q", string(resource.Data)) } if !strings.Contains(string(resource.Data), "NWN_USER_DIRECTORY="+userDir) { t.Fatalf("compiled payload missing NWN_USER_DIRECTORY: %q", string(resource.Data)) } } } } if !foundSource { t.Fatalf("expected source script resource in archive") } if !foundCompiled { t.Fatalf("expected compiled script resource in archive") } if _, err := os.Stat(userDir); err != nil { t.Fatalf("expected build to prepare NWN user directory: %v", err) } if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); err != nil { t.Fatalf("expected compiled script in .cache/ncs: %v", err) } if _, err := os.Stat(filepath.Join(root, ".nwn-assets", "ncs", "use_thing.ncs")); !os.IsNotExist(err) { t.Fatalf("expected no compiled script in .nwn-assets/ncs, got err=%v", err) } compareResult, err := Compare(p) if err != nil { t.Fatalf("compare: %v", err) } if compareResult.Checked != 4 { t.Fatalf("expected 4 compared resources, got %d", compareResult.Checked) } } func TestParseSteamLibraryFolders(t *testing.T) { root := t.TempDir() vdf := filepath.Join(root, "libraryfolders.vdf") mustWriteFile(t, vdf, `"libraryfolders" { "0" { "path" "/mnt/games/Steam" } "1" "D:\\SteamLibrary" } `) got := parseSteamLibraryFolders(vdf) if len(got) != 2 { t.Fatalf("expected 2 library roots, got %d (%v)", len(got), got) } if got[0] != filepath.Clean("/mnt/games/Steam") { t.Fatalf("unexpected first library root: %q", got[0]) } if got[1] != filepath.Clean(`D:\SteamLibrary`) { t.Fatalf("unexpected second library root: %q", got[1]) } } func TestBuildBuildsTopPackageAndInjectsHAKList(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustMkdir(t, filepath.Join(root, "topdata", "data", "repadjust")) mustMkdir(t, filepath.Join(root, "topdata", "assets", "gui")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["sow_top"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "topdata": { "source": "topdata", "build": "build/topdata" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ {"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}, {"label": "Mod_CustomTlk", "type": "CExoString", "value": "sow_tlk"} ] } } `) mustWriteFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") mustWriteFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ "output": "repadjust.2da", "columns": ["Label"], "rows": [{"id": 0, "Label": "TEST_LABEL"}] } `) mustWriteFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := Build(p) if err != nil { t.Fatalf("build: %v", err) } if _, err := os.Stat(filepath.Join(root, "build", "sow_top.hak")); err != nil { t.Fatalf("expected sow_top.hak: %v", err) } if _, err := os.Stat(filepath.Join(root, "build", "sow_tlk.tlk")); err != nil { t.Fatalf("expected sow_tlk.tlk: %v", err) } if result.TopPackageHAK == "" || result.TopPackageTLK == "" { t.Fatalf("expected top package paths in build result: %#v", result) } input, err := os.Open(result.ModulePath) if err != nil { t.Fatalf("open module: %v", err) } defer input.Close() archive, err := erf.Read(input) if err != nil { t.Fatalf("read module archive: %v", err) } var ifoData []byte for _, resource := range archive.Resources { ext, _ := erf.ExtensionForResourceType(resource.Type) if strings.ToLower(resource.Name)+"."+ext == "module.ifo" { ifoData = resource.Data break } } if ifoData == nil { t.Fatalf("expected module.ifo in built archive") } document, err := gff.Read(bytes.NewReader(ifoData)) if err != nil { t.Fatalf("decode module.ifo: %v", err) } foundHak := false for _, field := range document.Root.Fields { if field.Label != "Mod_HakList" { continue } list, ok := field.Value.(gff.ListValue) if !ok { t.Fatalf("expected Mod_HakList list, got %T", field.Value) } for _, item := range list { for _, nested := range item.Fields { if nested.Label == "Mod_Hak" && nested.Value == gff.StringValue("sow_top") { foundHak = true } } } } if !foundHak { t.Fatalf("expected built module to reference sow_top") } } func TestBuildModuleDoesNotCompileTopData(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustMkdir(t, filepath.Join(root, "topdata", "data", "repadjust")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["sow_top"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "topdata": { "source": "topdata", "build": "build/topdata" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ {"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}, {"label": "Mod_CustomTlk", "type": "CExoString", "value": "sow_tlk"} ] } } `) mustWriteFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") mustWriteFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ "output": "repadjust.2da", "columns": ["Label"], "rows": [{"id": 0, "Label": "TEST_LABEL"}] } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } var progress []string if _, err := BuildModuleWithProgress(p, func(message string) { progress = append(progress, message) }); err != nil { t.Fatalf("build module: %v", err) } joined := strings.Join(progress, "\n") if got := strings.Count(joined, "Building native topdata datasets..."); got != 0 { t.Fatalf("expected build-module to skip topdata compilation, got %d compile pass(es)\nprogress:\n%s", got, joined) } if _, err := os.Stat(filepath.Join(root, "build", "sow_top.hak")); !os.IsNotExist(err) { t.Fatalf("expected build-module not to generate sow_top.hak, got err=%v", err) } if _, err := os.Stat(filepath.Join(root, "build", "sow_tlk.tlk")); !os.IsNotExist(err) { t.Fatalf("expected build-module not to generate sow_tlk.tlk, got err=%v", err) } } func TestCompareIgnoresTopPackageStaleness(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustMkdir(t, filepath.Join(root, "topdata", "data", "repadjust")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["sow_top"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "topdata": { "source": "topdata", "build": "build/topdata" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ {"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}, {"label": "Mod_CustomTlk", "type": "CExoString", "value": "sow_tlk"} ] } } `) mustWriteFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") topdataBase := filepath.Join(root, "topdata", "data", "repadjust", "base.json") mustWriteFile(t, topdataBase, `{ "output": "repadjust.2da", "columns": ["Label"], "rows": [{"id": 0, "Label": "TEST_LABEL"}] } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("build module: %v", err) } newTime := time.Now().Add(2 * time.Second) if err := os.Chtimes(topdataBase, newTime, newTime); err != nil { t.Fatalf("touch topdata source: %v", err) } result, err := Compare(p) if err != nil { t.Fatalf("expected compare to ignore topdata staleness: %v", err) } if result.Checked != 1 { t.Fatalf("expected 1 compared resource, got %d", result.Checked) } } func TestCompareNormalizesModuleHakListFromConfig(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["manual_top"] }, "paths": { "source": "src", "assets": "assets", "build": "build" } }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [ { "struct_type": 8, "fields": [ { "label": "Mod_Hak", "type": "CExoString", "value": "stale_old_hak" } ] } ] } ] } }`) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("build module: %v", err) } if _, err := Compare(p); err != nil { t.Fatalf("compare project: %v", err) } } func TestBuildOrdersMultipleHAKGroups(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "vfx")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core", "manual_top", "group:vfx"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["core/**"] }, { "name": "vfx", "priority": 2, "max_bytes": 0, "split": false, "include": ["vfx/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), "core-data") mustWriteFile(t, filepath.Join(root, "assets", "vfx", "b.tga"), "vfx-data") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.HAKPaths) != 2 { t.Fatalf("expected 2 hak outputs, got %d", len(result.HAKPaths)) } rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if len(manifest.ModuleHAKs) != 3 { t.Fatalf("expected 3 module hak entries, got %#v", manifest.ModuleHAKs) } if manifest.ModuleHAKs[0] != "core" || manifest.ModuleHAKs[1] != "manual_top" || manifest.ModuleHAKs[2] != "vfx" { t.Fatalf("unexpected module hak order: %#v", manifest.ModuleHAKs) } } func TestBuildSplitsMultipleHAKGroupsDeterministically(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "vfx")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core", "manual_top", "group:vfx"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 310, "split": true, "include": ["core/**"] }, { "name": "vfx", "priority": 2, "max_bytes": 310, "split": true, "include": ["vfx/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), strings.Repeat("a", 80)) mustWriteFile(t, filepath.Join(root, "assets", "core", "b.tga"), strings.Repeat("b", 80)) mustWriteFile(t, filepath.Join(root, "assets", "vfx", "c.tga"), strings.Repeat("c", 80)) mustWriteFile(t, filepath.Join(root, "assets", "vfx", "d.tga"), strings.Repeat("d", 80)) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.HAKPaths) != 4 { t.Fatalf("expected 4 hak outputs, got %d", len(result.HAKPaths)) } expectedFiles := []string{ "core_01.hak", "core_02.hak", "vfx_01.hak", "vfx_02.hak", } for _, name := range expectedFiles { if _, err := os.Stat(filepath.Join(root, "build", name)); err != nil { t.Fatalf("expected split hak %s: %v", name, err) } } rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if len(manifest.HAKs) != 4 { t.Fatalf("expected 4 manifest hak entries, got %d", len(manifest.HAKs)) } actualManifestNames := make([]string, 0, len(manifest.HAKs)) for _, hak := range manifest.HAKs { actualManifestNames = append(actualManifestNames, hak.Name) } expectedManifestNames := []string{"core_01", "core_02", "vfx_01", "vfx_02"} if strings.Join(actualManifestNames, ",") != strings.Join(expectedManifestNames, ",") { t.Fatalf("unexpected manifest hak order: %#v", actualManifestNames) } expectedModuleHAKs := []string{"core_01", "core_02", "manual_top", "vfx_01", "vfx_02"} if strings.Join(manifest.ModuleHAKs, ",") != strings.Join(expectedModuleHAKs, ",") { t.Fatalf("unexpected module hak order: %#v", manifest.ModuleHAKs) } applyResult, err := ApplyHAKManifest(p, filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("apply hak manifest: %v", err) } if applyResult.HAKCount != 5 { t.Fatalf("expected 5 applied hak entries, got %d", applyResult.HAKCount) } updated, err := os.ReadFile(filepath.Join(root, "src", "module", "module.ifo.json")) if err != nil { t.Fatalf("read updated ifo: %v", err) } for _, name := range expectedModuleHAKs { if !strings.Contains(string(updated), "\"value\": \""+name+"\"") { t.Fatalf("updated ifo missing hak %s:\n%s", name, string(updated)) } } } func TestBuildHAKsWithOptionsBuildsSingleArchiveByName(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 310, "split": true, "include": ["core/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), strings.Repeat("a", 80)) mustWriteFile(t, filepath.Join(root, "assets", "core", "b.tga"), strings.Repeat("b", 80)) mustWriteFile(t, filepath.Join(root, "assets", "core", "c.tga"), strings.Repeat("c", 80)) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKsWithOptions(p, BuildHAKOptions{ArchiveNames: []string{"core_02"}}) if err != nil { t.Fatalf("build filtered haks: %v", err) } if len(result.HAKPaths) != 1 { t.Fatalf("expected 1 hak output, got %d", len(result.HAKPaths)) } if got := filepath.Base(result.HAKPaths[0]); got != "core_02.hak" { t.Fatalf("unexpected hak output %q", got) } if _, err := os.Stat(filepath.Join(root, "build", "core_01.hak")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected core_01.hak to be absent, got err=%v", err) } if _, err := os.Stat(filepath.Join(root, "build", "core.hak")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected unsplit core.hak to be absent, got err=%v", err) } } func TestBuildExcludesOptionalHAKsFromModuleOrder(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "optional")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core", "group:optional"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["core/**"] }, { "name": "optional", "priority": 2, "max_bytes": 0, "split": false, "optional": true, "include": ["optional/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), "core-data") mustWriteFile(t, filepath.Join(root, "assets", "optional", "b.tga"), "optional-data") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.HAKPaths) != 2 { t.Fatalf("expected 2 hak outputs, got %d", len(result.HAKPaths)) } rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if got, want := strings.Join(manifest.ModuleHAKs, ","), "core"; got != want { t.Fatalf("unexpected module hak order: got %q want %q", got, want) } if len(manifest.HAKs) != 2 { t.Fatalf("expected 2 manifest hak entries, got %d", len(manifest.HAKs)) } if manifest.HAKs[0].Name != "core" || manifest.HAKs[0].Optional { t.Fatalf("unexpected primary hak manifest entry: %#v", manifest.HAKs[0]) } if manifest.HAKs[1].Name != "optional" || !manifest.HAKs[1].Optional { t.Fatalf("unexpected optional hak manifest entry: %#v", manifest.HAKs[1]) } applyResult, err := ApplyHAKManifest(p, filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("apply hak manifest: %v", err) } if applyResult.HAKCount != 1 { t.Fatalf("expected 1 applied hak entry, got %d", applyResult.HAKCount) } updated, err := os.ReadFile(filepath.Join(root, "src", "module", "module.ifo.json")) if err != nil { t.Fatalf("read updated ifo: %v", err) } if !strings.Contains(string(updated), "\"value\": \"core\"") { t.Fatalf("updated ifo missing primary hak:\n%s", string(updated)) } if strings.Contains(string(updated), "\"value\": \"optional\"") { t.Fatalf("updated ifo should not contain optional hak:\n%s", string(updated)) } } func TestPlanHAKsWritesManifestWithoutArchives(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["core/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), "core-data") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := PlanHAKs(p) if err != nil { t.Fatalf("plan haks: %v", err) } if len(result.HAKPaths) != 0 { t.Fatalf("expected no hak outputs for plan-only, got %d", len(result.HAKPaths)) } manifestPath := filepath.Join(root, "build", "haks.json") rawManifest, err := os.ReadFile(manifestPath) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if len(manifest.HAKs) != 1 { t.Fatalf("expected 1 manifest hak entry, got %d", len(manifest.HAKs)) } if manifest.HAKs[0].ContentHash == "" { t.Fatal("expected plan-only manifest to include content hash") } if _, err := os.Stat(filepath.Join(root, "build", "core.hak")); !os.IsNotExist(err) { t.Fatalf("expected no built hak archive, got err=%v", err) } } func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "part", "belt")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_accessories")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "autogen": { "producers": [ { "id": "parts", "root": "part", "include": ["**/*.mdl"], "derive": { "kind": "trailing_numeric_suffix", "group_from": "first_path_segment" }, "manifest": { "release_tag": "parts-manifest-current", "asset_name": "sow-parts-manifest.json", "cache_name": "sow-parts-manifest.json" } }, { "id": "head_visualeffects", "root": "vfxs", "include": ["head_accessories/**/*.mdl", "head_features/**/*.mdl"], "derive": { "kind": "model_stem", "group_from": "first_path_segment" }, "manifest": { "release_tag": "head-vfx-manifest-current", "asset_name": "sow-head-vfx-manifest.json", "cache_name": "sow-head-vfx-manifest.json" } } ] }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["part/**", "vfxs/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt") mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "bandana") mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "hair") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.AutogenManifestPaths) != 2 { t.Fatalf("expected 2 autogen manifests, got %d", len(result.AutogenManifestPaths)) } partsRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-parts-manifest.json")) if err != nil { t.Fatalf("read parts manifest: %v", err) } if !strings.Contains(string(partsRaw), `"id": "parts"`) || !strings.Contains(string(partsRaw), `"row_id": 18`) { t.Fatalf("unexpected parts manifest contents:\n%s", string(partsRaw)) } headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-head-vfx-manifest.json")) if err != nil { t.Fatalf("read head visualeffects manifest: %v", err) } text := string(headRaw) for _, want := range []string{ `"id": "head_visualeffects"`, `"group": "head_accessories"`, `"model_stem": "hfx_bandana"`, `"group": "head_features"`, `"model_stem": "hfx_hair_bangs"`, } { if !strings.Contains(text, want) { t.Fatalf("expected head visualeffects manifest to contain %q, got:\n%s", want, text) } } } func TestBuildSkipsEmptyHAKGroupsInModuleOrder(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "vfx")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["manual_top", "group:core", "group:empty", "group:vfx"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 1024, "split": false, "include": ["core/**"] }, { "name": "empty", "priority": 2, "max_bytes": 1024, "split": false, "include": ["empty/**"] }, { "name": "vfx", "priority": 3, "max_bytes": 1024, "split": false, "include": ["vfx/**"] } ] }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "core_a.tga"), "core-a") mustWriteFile(t, filepath.Join(root, "assets", "vfx", "vfx_a.tga"), "vfx-a") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } rawManifest, err := os.ReadFile(result.Manifest) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if got, want := strings.Join(manifest.ModuleHAKs, ","), "manual_top,core,vfx"; got != want { t.Fatalf("unexpected module hak order: got %q want %q", got, want) } } func TestBuildHAKsReusesUnchangedChunks(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "vfx")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core", "group:vfx"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 1024, "split": false, "include": ["core/**"] }, { "name": "vfx", "priority": 2, "max_bytes": 1024, "split": false, "include": ["vfx/**"] } ] }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } }`) mustWriteFile(t, filepath.Join(root, "assets", "core", "core_a.tga"), "core-a") mustWriteFile(t, filepath.Join(root, "assets", "vfx", "vfx_a.tga"), "vfx-a") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildHAKs(p); err != nil { t.Fatalf("first build haks: %v", err) } corePath := filepath.Join(root, "build", "core.hak") vfxPath := filepath.Join(root, "build", "vfx.hak") coreInfoBefore, err := os.Stat(corePath) if err != nil { t.Fatalf("stat core before: %v", err) } vfxInfoBefore, err := os.Stat(vfxPath) if err != nil { t.Fatalf("stat vfx before: %v", err) } time.Sleep(1100 * time.Millisecond) if _, err := BuildHAKs(p); err != nil { t.Fatalf("second build haks: %v", err) } coreInfoAfter, err := os.Stat(corePath) if err != nil { t.Fatalf("stat core after: %v", err) } vfxInfoAfter, err := os.Stat(vfxPath) if err != nil { t.Fatalf("stat vfx after: %v", err) } if !coreInfoAfter.ModTime().Equal(coreInfoBefore.ModTime()) { t.Fatalf("expected unchanged core.hak to be reused") } if !vfxInfoAfter.ModTime().Equal(vfxInfoBefore.ModTime()) { t.Fatalf("expected unchanged vfx.hak to be reused") } rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } var manifest BuildManifest if err := json.Unmarshal(rawManifest, &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } for _, hak := range manifest.HAKs { if hak.ContentHash == "" { t.Fatalf("expected content hash for %s", hak.Name) } } } func TestBuildModulePrefersManifestModuleHAKOrder(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["manual_top", "group:core"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 1024, "split": false, "include": ["core/**"] } ] }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } } `) mustWriteFile(t, filepath.Join(root, "build", "haks.json"), `{ "module_haks": ["sow_top", "sow_core_01", "sow_appr_01"], "haks": [] } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildModule(p) if err != nil { t.Fatalf("build module: %v", err) } archive, err := readArchive(result.ModulePath) if err != nil { t.Fatalf("read module archive: %v", err) } var ifo erf.Resource found := false for _, resource := range archive.Resources { if resource.Name == "module" { ifo = resource found = true break } } if !found { t.Fatalf("module.ifo not found in built archive") } document, err := gff.Read(bytes.NewReader(ifo.Data)) if err != nil { t.Fatalf("decode module ifo: %v", err) } var got []string for _, field := range document.Root.Fields { if field.Label != "Mod_HakList" { continue } list, ok := field.Value.(gff.ListValue) if !ok { t.Fatalf("expected Mod_HakList list, got %T", field.Value) } for _, item := range list { for _, nested := range item.Fields { if nested.Label == "Mod_Hak" { got = append(got, string(nested.Value.(gff.StringValue))) } } } } if want := "sow_top,sow_core_01,sow_appr_01"; strings.Join(got, ",") != want { t.Fatalf("unexpected module hak order: got %q want %q", strings.Join(got, ","), want) } } func TestBuildHAKsRebuildsOnlyChangedChunks(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "assets", "vfx")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core", "group:vfx"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 1024, "split": false, "include": ["core/**"] }, { "name": "vfx", "priority": 2, "max_bytes": 1024, "split": false, "include": ["vfx/**"] } ] }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" }, { "label": "Mod_HakList", "type": "List", "value": [] } ] } }`) mustWriteFile(t, filepath.Join(root, "assets", "core", "core_a.tga"), "core-a") mustWriteFile(t, filepath.Join(root, "assets", "vfx", "vfx_a.tga"), "vfx-a") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildHAKs(p); err != nil { t.Fatalf("first build haks: %v", err) } corePath := filepath.Join(root, "build", "core.hak") vfxPath := filepath.Join(root, "build", "vfx.hak") coreInfoBefore, err := os.Stat(corePath) if err != nil { t.Fatalf("stat core before: %v", err) } vfxInfoBefore, err := os.Stat(vfxPath) if err != nil { t.Fatalf("stat vfx before: %v", err) } time.Sleep(1100 * time.Millisecond) mustWriteFile(t, filepath.Join(root, "assets", "core", "core_a.tga"), "core-a-updated") if err := p.Scan(); err != nil { t.Fatalf("rescan after change: %v", err) } if _, err := BuildHAKs(p); err != nil { t.Fatalf("second build haks: %v", err) } coreInfoAfter, err := os.Stat(corePath) if err != nil { t.Fatalf("stat core after: %v", err) } vfxInfoAfter, err := os.Stat(vfxPath) if err != nil { t.Fatalf("stat vfx after: %v", err) } if !coreInfoAfter.ModTime().After(coreInfoBefore.ModTime()) { t.Fatalf("expected changed core.hak to be rebuilt") } if !vfxInfoAfter.ModTime().Equal(vfxInfoBefore.ModTime()) { t.Fatalf("expected unchanged vfx.hak to be reused") } } func TestPlanHAKChunksPutsNewestAssetsInLastChunk(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "core")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:core"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 400, "split": true, "include": ["core/**"] } ] }`) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Test Module" } ] } } `) runGitTest(t, root, "init") runGitTest(t, root, "config", "user.name", "Test User") runGitTest(t, root, "config", "user.email", "test@example.com") mustWriteFile(t, filepath.Join(root, "assets", "core", "old_a.tga"), strings.Repeat("a", 120)) runGitCommitTest(t, root, "2001-01-01T00:00:00Z", "add old_a") mustWriteFile(t, filepath.Join(root, "assets", "core", "old_b.tga"), strings.Repeat("b", 120)) runGitCommitTest(t, root, "2002-01-01T00:00:00Z", "add old_b") mustWriteFile(t, filepath.Join(root, "assets", "core", "new_c.tga"), strings.Repeat("c", 120)) runGitCommitTest(t, root, "2003-01-01T00:00:00Z", "add new_c") now := time.Now() if err := os.Chtimes(filepath.Join(root, "assets", "core", "old_a.tga"), now, now); err != nil { t.Fatalf("chtimes old_a: %v", err) } if err := os.Chtimes(filepath.Join(root, "assets", "core", "old_b.tga"), now.Add(-2*time.Hour), now.Add(-2*time.Hour)); err != nil { t.Fatalf("chtimes old_b: %v", err) } if err := os.Chtimes(filepath.Join(root, "assets", "core", "new_c.tga"), now.Add(-4*time.Hour), now.Add(-4*time.Hour)); err != nil { t.Fatalf("chtimes new_c: %v", err) } p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } assets, err := collectAssetResources(p, false, nil, nil) if err != nil { t.Fatalf("collect asset resources: %v", err) } slices.SortFunc(assets, compareAssetBuildOrder) twoAssetSize := erf.ArchiveSize(resourceSlice(assets[:2])) threeAssetSize := erf.ArchiveSize(resourceSlice(assets)) if threeAssetSize <= twoAssetSize { t.Fatalf("expected three-asset archive to be larger than two-asset archive") } p.Config.HAKs[0].MaxBytes = twoAssetSize chunks, err := planHAKChunks(p, assets) if err != nil { t.Fatalf("plan hak chunks: %v", err) } if len(chunks) != 2 { t.Fatalf("expected 2 chunks, got %d", len(chunks)) } firstChunkAssets := make([]string, 0, len(chunks[0].Assets)) for _, asset := range chunks[0].Assets { firstChunkAssets = append(firstChunkAssets, asset.Rel) } lastChunkAssets := make([]string, 0, len(chunks[1].Assets)) for _, asset := range chunks[1].Assets { lastChunkAssets = append(lastChunkAssets, asset.Rel) } if got, want := strings.Join(firstChunkAssets, ","), "core/old_a.tga,core/old_b.tga"; got != want { t.Fatalf("unexpected first chunk assets: got %q want %q", got, want) } if got, want := strings.Join(lastChunkAssets, ","), "core/new_c.tga"; got != want { t.Fatalf("unexpected last chunk assets: got %q want %q", got, want) } } func TestPlanHAKChunksKeepsPreviousSplitsAndPacksNewAssetsAtTail(t *testing.T) { oldB := testAssetResource("core/old_b.tga", "old_b", "b", time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC)) oldC := testAssetResource("core/old_c.tga", "old_c", "c", time.Date(2003, 1, 1, 0, 0, 0, 0, time.UTC)) newA := testAssetResource("core/new_a.tga", "new_a", "a", time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)) twoAssetSize := erf.ArchiveSize([]erf.Resource{oldB.Resource, oldC.Resource}) p := &project.Project{ Config: project.Config{ HAKs: []project.HAKConfig{ { Name: "core", Priority: 1, MaxBytes: twoAssetSize, Split: true, Include: []string{"core/**"}, }, }, }, } previous := &BuildManifest{ HAKs: []BuildManifestHAK{ {Name: "core_01", Group: "core", Priority: 1, MaxBytes: twoAssetSize, Assets: []string{"core/old_b.tga"}}, {Name: "core_02", Group: "core", Priority: 1, MaxBytes: twoAssetSize, Assets: []string{"core/old_c.tga"}}, }, } chunks, err := planHAKChunksWithPrevious(p, []assetResource{oldB, oldC, newA}, previous) if err != nil { t.Fatalf("plan hak chunks: %v", err) } if len(chunks) != 2 { t.Fatalf("expected 2 chunks, got %d", len(chunks)) } if got, want := strings.Join(chunkAssetRels(chunks[0]), ","), "core/old_b.tga"; got != want { t.Fatalf("unexpected first chunk assets: got %q want %q", got, want) } if got, want := strings.Join(chunkAssetRels(chunks[1]), ","), "core/old_c.tga,core/new_a.tga"; got != want { t.Fatalf("unexpected tail chunk assets: got %q want %q", got, want) } } func testAssetResource(rel, name, payload string, createdAt time.Time) assetResource { resource := erf.Resource{ Name: strings.ToLower(name), Type: 0x0003, Data: []byte(strings.Repeat(payload, 80)), } return assetResource{ Rel: rel, Resource: resource, Size: erf.ArchiveSize([]erf.Resource{resource}), CreatedAt: createdAt, ContentID: "test:" + name, } } func chunkAssetRels(chunk hakChunk) []string { rels := make([]string, 0, len(chunk.Assets)) for _, asset := range chunk.Assets { rels = append(rels, asset.Rel) } return rels } func TestCollectLFSAssetInfoUsesGitCommonDirForWorktree(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } parent := t.TempDir() repoRoot := filepath.Join(parent, "repo") worktreeRoot := filepath.Join(parent, "worktree") mustMkdir(t, repoRoot) runGitTest(t, repoRoot, "init") mustWriteFile(t, filepath.Join(repoRoot, "README.md"), "test\n") runGitTest(t, repoRoot, "add", ".") runGitTest(t, repoRoot, "config", "user.name", "Test User") runGitTest(t, repoRoot, "config", "user.email", "test@example.com") runGitTest(t, repoRoot, "commit", "-m", "initial") runGitTest(t, repoRoot, "worktree", "add", worktreeRoot, "HEAD") oid := strings.Repeat("a", 64) assetRel := filepath.Join("core", "foo.mdl") assetPath := filepath.Join(worktreeRoot, "assets", assetRel) mustMkdir(t, filepath.Dir(assetPath)) mustWriteFile(t, assetPath, "version https://git-lfs.github.com/spec/v1\n"+"oid sha256:"+oid+"\n"+"size 5\n") lfsObjectPath := filepath.Join(repoRoot, ".git", "lfs", "objects", oid[:2], oid[2:4], oid) mustMkdir(t, filepath.Dir(lfsObjectPath)) mustWriteFile(t, lfsObjectPath, "model") proj := &project.Project{ Root: worktreeRoot, Config: project.Config{ Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"}, }, Inventory: project.Inventory{ AssetFiles: []string{filepath.ToSlash(assetRel)}, }, } infos, err := collectLFSAssetInfo(proj) if err != nil { t.Fatalf("collect lfs asset info: %v", err) } info, ok := infos[filepath.ToSlash(assetRel)] if !ok { t.Fatal("expected LFS asset info") } if got, want := info.ObjectPath, lfsObjectPath; got != want { t.Fatalf("unexpected LFS object path:\ngot %s\nwant %s", got, want) } if _, err := lfsPathResource(assetPath, info, true); err != nil { t.Fatalf("expected worktree LFS object to resolve: %v", err) } } func runGitCommitTest(t *testing.T, dir, timestamp, message string) { t.Helper() runGitTest(t, dir, "add", ".") cmd := exec.Command("git", "commit", "-m", message) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+timestamp, "GIT_COMMITTER_DATE="+timestamp, ) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git commit failed: %v\n%s", err, string(output)) } } func runGitTest(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) } } func TestExtractOverwritesAndRemovesStaleFiles(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "src", "areas")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Original Module" } ] } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("build module: %v", err) } mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Changed In Toolset" } ] } } `) mustWriteFile(t, filepath.Join(root, "src", "areas", "area001.are.json"), `{"stale": true}`) if err := p.Scan(); err != nil { t.Fatalf("rescan before extract: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if result.Overwritten != 1 { t.Fatalf("expected 1 overwritten file, got %d", result.Overwritten) } if result.Removed != 1 { t.Fatalf("expected 1 removed stale file, got %d", result.Removed) } raw, err := os.ReadFile(filepath.Join(root, "src", "module", "module.ifo.json")) if err != nil { t.Fatalf("read extracted module file: %v", err) } if !strings.Contains(string(raw), "Original Module") { t.Fatalf("expected extracted module file to be overwritten with archive contents:\n%s", string(raw)) } if _, err := os.Stat(filepath.Join(root, "src", "areas", "area001.are.json")); !os.IsNotExist(err) { t.Fatalf("expected stale area file to be removed, stat err=%v", err) } } func TestExtractNormalizesResourceNamesToLowercase(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "blueprints", "items")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" } } `) mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "I_ELVENCHAIN.uti.json"), `{ "file_type": "UTI ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "LocalizedName", "type": "CExoString", "value": "Elven Chain" } ] } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("build module: %v", err) } if err := os.Remove(filepath.Join(root, "src", "blueprints", "items", "I_ELVENCHAIN.uti.json")); err != nil { t.Fatalf("remove uppercase source file: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("rescan before extract: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if result.Written != 1 { t.Fatalf("expected 1 written file, got %d", result.Written) } if _, err := os.Stat(filepath.Join(root, "src", "blueprints", "items", "i_elvenchain.uti.json")); err != nil { t.Fatalf("expected lowercase extracted resource: %v", err) } if _, err := os.Stat(filepath.Join(root, "src", "blueprints", "items", "I_ELVENCHAIN.uti.json")); !os.IsNotExist(err) { t.Fatalf("expected uppercase extracted resource path to be absent, stat err=%v", err) } } func TestCompareFailsWhenBuildIsStale(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" } } `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Original Module" } ] } } `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if _, err := BuildModule(p); err != nil { t.Fatalf("build module: %v", err) } modulePath := filepath.Join(root, "build", "testmod.mod") moduleInfo, err := os.Stat(modulePath) if err != nil { t.Fatalf("stat built module: %v", err) } staleSourceTime := moduleInfo.ModTime().Add(2 * time.Second) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [ { "label": "Mod_Name", "type": "CExoString", "value": "Updated Module" } ] } } `) if err := os.Chtimes(filepath.Join(root, "src", "module", "module.ifo.json"), staleSourceTime, staleSourceTime); err != nil { t.Fatalf("chtimes updated source: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("rescan: %v", err) } _, err = Compare(p) if err == nil { t.Fatal("expected stale compare error") } if !strings.Contains(err.Error(), "built archives are older than source file") { t.Fatalf("unexpected compare error: %v", err) } } func mustMkdir(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { t.Fatalf("mkdir %s: %v", path, err) } } func mustWriteFile(t *testing.T, path, data string) { t.Helper() if err := os.WriteFile(path, []byte(data), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func TestBuildHAKsFailsForDuplicateResourcesInSameHAK(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "core", "a")) mustMkdir(t, filepath.Join(root, "assets", "core", "b")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod" }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["core/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "core", "a", "shared.mdl"), "one") mustWriteFile(t, filepath.Join(root, "assets", "core", "b", "shared.mdl"), "two") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } _, err = BuildHAKs(p) if err == nil { t.Fatal("expected duplicate same-hak build error") } if !strings.Contains(err.Error(), "validation failed with 1 error(s)") { t.Fatalf("unexpected error: %v", err) } } func TestBuildHAKsConvertsMusicSourcesAndWritesCreditsArtifacts(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets", "envi", "music", "westgate")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Assets", "resref": "testassets" }, "paths": { "assets": "assets", "build": "build" }, "music": { "prefixes": { "envi/music/westgate": "mus_wg_" } }, "haks": [ { "name": "envi", "priority": 1, "max_bytes": 1048576, "split": false, "include": ["envi/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3") mustWriteFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n") ffprobePath := filepath.Join(root, "ffprobe") mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Unknown\",\"title\":\"Broken Metadata\"}}}'\n") if err := os.Chmod(ffprobePath, 0o755); err != nil { t.Fatalf("chmod ffprobe: %v", err) } ffmpegPath := filepath.Join(root, "ffmpeg") mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'bmu-data' > \"$last\"\n") if err := os.Chmod(ffmpegPath, 0o755); err != nil { t.Fatalf("chmod ffmpeg: %v", err) } t.Setenv("SOW_FFMPEG", ffmpegPath) t.Setenv("SOW_FFPROBE", ffprobePath) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.ValidateLayout(); err != nil { t.Fatalf("validate layout: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } result, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks: %v", err) } if len(result.CreditsArtifactPaths) == 0 { t.Fatal("expected credits artifacts to be written") } manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } if !strings.Contains(string(manifestRaw), "envi/music/westgate/mus_wg_andnc.bmu") { t.Fatalf("expected converted bmu in manifest, got %s", string(manifestRaw)) } if strings.Contains(string(manifestRaw), "AleandAnecdotes.mp3") { t.Fatalf("did not expect source mp3 in manifest, got %s", string(manifestRaw)) } creditsRaw, err := os.ReadFile(filepath.Join(root, ".cache", "credits", "envi", "music", "westgate", "CREDITS.md")) if err != nil { t.Fatalf("read generated credits: %v", err) } creditsText := string(creditsRaw) if !strings.Contains(creditsText, "Darren Curtis") || !strings.Contains(creditsText, "mus_wg_andnc.bmu") { t.Fatalf("unexpected generated credits contents: %s", creditsText) } if strings.Contains(creditsText, "Broken Metadata") { t.Fatalf("manual credits overlay should override bad metadata: %s", creditsText) } inventoryRaw, err := os.ReadFile(filepath.Join(root, ".cache", "credits", "credits.json")) if err != nil { t.Fatalf("read credits inventory: %v", err) } inventoryText := string(inventoryRaw) if !strings.Contains(inventoryText, "assets/envi/music/westgate/CREDITS.md") { t.Fatalf("expected authored credits source in inventory: %s", inventoryText) } if !strings.Contains(inventoryText, ".cache/credits/envi/music/westgate/CREDITS.md") { t.Fatalf("expected generated credits source in inventory: %s", inventoryText) } if _, err := os.Stat(filepath.Join(root, ".cache", "music")); !os.IsNotExist(err) { t.Fatalf("expected temporary music staging to be cleaned, got err=%v", err) } secondResult, err := BuildHAKs(p) if err != nil { t.Fatalf("build haks second pass: %v", err) } if secondResult.CreditsSummary.ChangedFiles != 0 { t.Fatalf("expected second credits refresh to be unchanged, got %d changed files", secondResult.CreditsSummary.ChangedFiles) } if secondResult.CreditsSummary.TrackCount != 1 { t.Fatalf("expected one tracked music conversion, got %d", secondResult.CreditsSummary.TrackCount) } } func TestUniqueMusicNameHandlesShortStemCollisions(t *testing.T) { used := map[string]struct{}{ "mus_wg_mystc": {}, } got := uniqueMusicName("mus_wg_mystc", used) if got != "mus_wg_mystc_1" { t.Fatalf("unexpected collision result: got %q want %q", got, "mus_wg_mystc_1") } }