package pipeline import ( "bytes" "encoding/json" "errors" "os" "os/exec" "path/filepath" "slices" "strings" "testing" "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) func TestBuildHAKsCleansFailedTemporaryArchive(t *testing.T) { root := t.TempDir() p := loadDirectProject(t, root) blobs := filepath.Join(root, "blobs") sha := strings.Repeat("a", 64) writeBlobAt(t, blobs, sha, "wxyz") manifestPath := writeDirectManifest(t, root, map[string]SourceAsset{"core/a.tga": {SHA256: sha, SizeBytes: 4}}, []string{"core/a.tga"}) tmpPath := filepath.Join(root, "build", "core_01.hak.tmp") mustWriteFile(t, tmpPath, "stale partial archive") _, err := BuildHAKsWithOptions(p, BuildHAKOptions{ SourceManifestPath: manifestPath, ContentAddressedRoot: blobs, }) if err == nil { t.Fatal("expected corrupt input to fail") } if _, err := os.Stat(filepath.Join(root, "build", "core_01.hak")); !errors.Is(err, os.ErrNotExist) { t.Fatal("corrupt input must not leave a completed hak") } if _, err := os.Stat(tmpPath); !errors.Is(err, os.ErrNotExist) { t.Fatal("failed build must clean temporary hak") } } 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" }, "extract": { "archives": ["*.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) } 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 TestExtractReadsSoundBlueprintResources(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) } archiveFile, err := os.Create(filepath.Join(root, "build", "testmod.mod")) if err != nil { t.Fatalf("create mod: %v", err) } if err := erf.Write(archiveFile, erf.New("MOD ", []erf.Resource{ {Name: "S_AL_CANDLE01", Type: 0x07F3, Data: minimalGFF(t, "UTS")}, })); err != nil { t.Fatalf("write mod: %v", err) } if err := archiveFile.Close(); err != nil { t.Fatalf("close mod: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if result.Written != 1 { t.Fatalf("expected 1 extracted sound blueprint, got %d", result.Written) } if _, err := os.Stat(filepath.Join(root, "src", "blueprints", "sounds", "s_al_candle01.uts.json")); err != nil { t.Fatalf("expected extracted sound blueprint: %v", err) } } func TestExtractDefaultOnlyReadsConfiguredModuleArchive(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "assets", "mdl")) 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 `) 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" } ] } } `) mustWriteFile(t, filepath.Join(root, "assets", "mdl", "stale_asset.mdl"), "keep") 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", "module", "module.ifo.json")); err != nil { t.Fatalf("remove source file: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("rescan before extract: %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")}, })); 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) != 0 { t.Fatalf("expected default extract to ignore haks, got %#v", result.HAKPaths) } if _, err := os.Stat(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil { t.Fatalf("expected module source to be restored: %v", err) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "stale_asset.mdl")); err != nil { t.Fatalf("expected asset cleanup to be skipped for module-only extract: %v", err) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "test_vfx.mdl")); !os.IsNotExist(err) { t.Fatalf("expected hak asset not to be extracted by default, err=%v", err) } } func TestExtractRejectsERFArchiveTargets(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: archives: - imported.erf `) erfFile, err := os.Create(filepath.Join(root, "build", "imported.erf")) if err != nil { t.Fatalf("create erf: %v", err) } if err := erf.Write(erfFile, erf.New("ERF ", nil)); err != nil { t.Fatalf("write erf: %v", err) } if err := erfFile.Close(); err != nil { t.Fatalf("close erf: %v", err) } p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } _, err = Extract(p) if err == nil { t.Fatal("expected erf extraction to fail") } if !strings.Contains(err.Error(), "ERF extraction is unsafe") { t.Fatalf("expected unsafe erf error, got %v", err) } } func TestExtractRejectsEscapingArchiveOverride(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 `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } _, err = Extract(p, "../outside.mod") if err == nil { t.Fatal("expected escaping archive override to fail") } if !strings.Contains(err.Error(), "must not escape paths.build") { t.Fatalf("expected escaping archive error, got %v", err) } } func TestExtractConsumesConfiguredHAKArchiveAfterSuccess(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: archives: - vfx.hak consume_archives: true `) hakPath := filepath.Join(root, "build", "vfx.hak") hakFile, err := os.Create(hakPath) 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")}, })); err != nil { t.Fatalf("write hak: %v", err) } if err := hakFile.Close(); err != nil { t.Fatalf("close hak: %v", err) } p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if len(result.DeletedArchivePaths) != 1 || result.DeletedArchivePaths[0] != hakPath { t.Fatalf("expected consumed hak path, got %#v", result.DeletedArchivePaths) } if _, err := os.Stat(hakPath); !os.IsNotExist(err) { t.Fatalf("expected hak archive to be deleted, stat err=%v", err) } if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "test_vfx.mdl")); err != nil { t.Fatalf("expected extracted hak asset: %v", err) } } func TestExtractConsumeArchivesFalseOverridesLegacyDeleteFlag(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.yaml"), ` module: name: Test Module resref: testmod paths: source: src assets: assets build: build extract: consume_archives: false 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 before extract: %v", err) } result, err := Extract(p) if err != nil { t.Fatalf("extract: %v", err) } if len(result.DeletedArchivePaths) != 0 { t.Fatalf("expected no deleted archives, got %#v", result.DeletedArchivePaths) } if _, err := os.Stat(buildResult.ModulePath); err != nil { t.Fatalf("expected module archive to remain, stat err=%v", err) } } func TestExtractRefusesAssetsWhenAssetsPathIsUnset(t *testing.T) { root := t.TempDir() t.Chdir(root) mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "root_level_asset.mdl"), "must stay") mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src build: build `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := p.Scan(); err != nil { t.Fatalf("scan: %v", err) } if len(p.Inventory.AssetFiles) != 0 { t.Fatalf("expected unset assets path not to inventory repository-root assets, got %#v", p.Inventory.AssetFiles) } 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) } 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")}, })); err != nil { t.Fatalf("write hak: %v", err) } if err := hakFile.Close(); err != nil { t.Fatalf("close hak: %v", err) } _, err = Extract(p, "vfx.hak") if err == nil { t.Fatal("expected extract to fail") } if !strings.Contains(err.Error(), "paths.assets is not configured") { t.Fatalf("expected missing assets path error, got %v", err) } if _, statErr := os.Stat(filepath.Join(root, "mdl", "test_vfx.mdl")); !os.IsNotExist(statErr) { t.Fatalf("expected no root-level extracted asset, stat err=%v", statErr) } if _, statErr := os.Stat(filepath.Join(root, "root_level_asset.mdl")); statErr != nil { t.Fatalf("expected pre-existing root-level asset to remain, stat err=%v", statErr) } } func TestExtractRefusesAssetsWhenAssetsPathIsRepositoryRoot(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src assets: . build: build `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %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) } 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")}, })); err != nil { t.Fatalf("write hak: %v", err) } if err := hakFile.Close(); err != nil { t.Fatalf("close hak: %v", err) } _, err = Extract(p, "vfx.hak") if err == nil { t.Fatal("expected extract to fail") } if !strings.Contains(err.Error(), "paths.assets resolves to unsafe extraction root") { t.Fatalf("expected unsafe assets root error, got %v", err) } if _, statErr := os.Stat(filepath.Join(root, "mdl", "test_vfx.mdl")); !os.IsNotExist(statErr) { t.Fatalf("expected no root-level extracted asset, stat err=%v", statErr) } } func TestExtractRefusesSourceWhenSourcePathIsUnset(t *testing.T) { root := t.TempDir() t.Chdir(root) 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: assets: assets build: build `) p, err := project.Load(root) if err != nil { t.Fatalf("load project: %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: "test_script", Type: 0x07D9, Data: []byte("void main() {}\n")}, })); err != nil { t.Fatalf("write mod: %v", err) } if err := modFile.Close(); err != nil { t.Fatalf("close mod: %v", err) } _, err = Extract(p) if err == nil { t.Fatal("expected extract to fail") } if !strings.Contains(err.Error(), "paths.source is not configured") { t.Fatalf("expected missing source path error, got %v", err) } if _, statErr := os.Stat(filepath.Join(root, "scripts", "test_script.nss")); !os.IsNotExist(statErr) { t.Fatalf("expected no root-level extracted script, stat err=%v", statErr) } } 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: archives: - wanted.hak 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 TestApplyHAKManifestRefusesUnsetSourcePath(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "build")) mustMkdir(t, filepath.Join(root, "module")) mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: assets: assets build: build `) mustWriteFile(t, filepath.Join(root, "build", "haks.json"), `{"module_haks":["core"],"haks":[]}`) mustWriteFile(t, filepath.Join(root, "module", "module.ifo.json"), "{}\n") p, err := project.Load(root) if err != nil { t.Fatalf("load project: %v", err) } _, err = ApplyHAKManifest(p, filepath.Join(root, "build", "haks.json")) if err == nil { t.Fatal("expected apply manifest to fail") } if !strings.Contains(err.Error(), "paths.source is not configured") { t.Fatalf("expected missing source path error, got %v", err) } raw, readErr := os.ReadFile(filepath.Join(root, "module", "module.ifo.json")) if readErr != nil { t.Fatalf("read root module file: %v", readErr) } if string(raw) != "{}\n" { t.Fatalf("expected root module file to remain unchanged, got %q", string(raw)) } } 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 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 TestBuildHAKsWritesConfiguredAccessoryVisualeffectsAutogenManifest(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "chest_accessories")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_accessories")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_decorations")) 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": "accessory_visualeffects", "root": "vfxs", "include": ["chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"], "derive": { "kind": "model_stem", "group_from": "first_path_segment" }, "manifest": { "release_tag": "head-vfx-manifest-current", "asset_name": "sow-accessory-vfx-manifest.json", "cache_name": "sow-accessory-vfx-manifest.json" }, "accessory_visualeffects": { "groups": { "head_accessories": { "prefix": "head_accessories", "model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"] } } } } ] }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["vfxs/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "sash") mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "bandana") mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_decorations", "hfx_laurel.mdl"), "laurel") 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) != 1 { t.Fatalf("expected 1 autogen manifest, got %d", len(result.AutogenManifestPaths)) } if _, err := os.Stat(filepath.Join(root, "build", "sow-parts-manifest.json")); !os.IsNotExist(err) { t.Fatalf("expected no parts manifest, got stat error %v", err) } headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-accessory-vfx-manifest.json")) if err != nil { t.Fatalf("read accessory visualeffects manifest: %v", err) } text := string(headRaw) for _, want := range []string{ `"id": "accessory_visualeffects"`, `"group": "chest_accessories"`, `"model_stem": "tfx_sash"`, `"group": "head_accessories"`, `"model_stem": "hfx_bandana"`, `"accessory_visualeffects": {`, `"model_columns": [`, `"Imp_Root_H_Node"`, `"group": "head_decorations"`, `"model_stem": "hfx_laurel"`, `"group": "head_features"`, `"model_stem": "hfx_hair_bangs"`, } { if !strings.Contains(text, want) { t.Fatalf("expected accessory visualeffects manifest to contain %q, got:\n%s", want, text) } } } func TestBuildHAKsWritesFolderCategoryInAccessoryVisualeffectsAutogenManifest(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_features", "sinfar", "ears_plt")) 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": "accessory_visualeffects", "root": "vfxs", "include": ["head_features/**/*.mdl"], "derive": { "kind": "model_stem", "group_from": "first_path_segment" }, "manifest": { "release_tag": "head-vfx-manifest-current", "asset_name": "sow-accessory-vfx-manifest.json", "cache_name": "sow-accessory-vfx-manifest.json" }, "accessory_visualeffects": { "groups": { "head_features": { "model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"] } }, "group_token_source": "folder_name", "category_from": "immediate_parent", "delimiter": "/", "case": "preserve", "strip_model_prefixes": ["hfx_"], "key_format": "{dataset}:{group}{delimiter}{category_segment}{stem}", "label_format": "{group}{delimiter}{category_segment}{stem}" } } ] }, "haks": [ { "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["vfxs/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "sinfar", "ears_plt", "hfx_Ear_EL_L.mdl"), "ear") 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("build haks: %v", err) } headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-accessory-vfx-manifest.json")) if err != nil { t.Fatalf("read accessory visualeffects manifest: %v", err) } text := string(headRaw) for _, want := range []string{ `"group": "head_features"`, `"subgroup": "sinfar/ears_plt"`, `"category": "ears_plt"`, `"model_stem": "hfx_Ear_EL_L"`, `"source": "head_features/sinfar/ears_plt/hfx_Ear_EL_L.mdl"`, `"group_token_source": "folder_name"`, `"category_from": "immediate_parent"`, } { if !strings.Contains(text, want) { t.Fatalf("expected accessory visualeffects manifest to contain %q, got:\n%s", want, text) } } } func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(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, "topdata", "data", "parts")) mustMkdir(t, filepath.Join(root, "topdata", "data", "parts", "modules")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:sow_part"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "generated_assets": { "topdata_2da": [ { "id": "parts", "source": "topdata", "output": "{paths.cache}/generated-assets/parts-2da", "include_datasets": ["parts/**"], "package_root": "part", "autogen": { "id": "parts", "mode": "parts_rows", "root": "part", "include": ["**/*.mdl"], "derive": { "kind": "trailing_numeric_suffix", "group_from": "first_path_segment" }, "parts_rows": { "row_defaults": {"COSTMODIFIER": "0"}, "acbonus": { "default": { "strategy": "ascending_row_id_sort_key", "max_row_id": 999, "divisor": 100, "format": "%.2f" }, "datasets": { "chest": {"strategy": "fixed", "value": "0.00"} } } } } } ] }, "haks": [ { "name": "sow_part", "priority": 1, "max_bytes": 0, "split": false, "include": ["part/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt") mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt117.mdl"), "belt") mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{ "output": "parts_belt.2da", "columns": ["COSTMODIFIER", "ACBONUS"], "rows": [ {"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"}, {"id": 17, "COSTMODIFIER": 0, "ACBONUS": "0.90"}, {"id": 19, "COSTMODIFIER": 0, "ACBONUS": "****"}, {"id": 117, "COSTMODIFIER": 0, "ACBONUS": "****"} ] } `) mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "modules", "ovr_belt_acbonus_zero.json"), `{ "overrides": [ {"id": 18, "ACBONUS": "0.75"} ] } `) 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) != 1 { t.Fatalf("expected one HAK, got %#v", result.HAKPaths) } raw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read haks manifest: %v", err) } manifestText := string(raw) if !strings.Contains(manifestText, `"part/parts_belt.2da"`) { t.Fatalf("expected generated parts 2da in manifest, got:\n%s", manifestText) } partsRaw, err := os.ReadFile(filepath.Join(root, ".cache", "generated-assets", "parts-2da", "parts_belt.2da")) if err != nil { t.Fatalf("read generated parts 2da: %v", err) } partsText := string(partsRaw) for _, want := range []string{ "0\t1\t0.00", "17\t0\t0.17", "18\t0\t0.75", "19\t****\t****", "117\t0\t1.17", } { if !strings.Contains(partsText, want) { t.Fatalf("expected generated parts 2da to contain %q after normalization and overrides, got:\n%s", want, partsText) } } if strings.Contains(partsText, "17\t0\t0.90") { t.Fatalf("expected authored and generated rows in parts 2da, got:\n%s", partsText) } } func TestBuildHAKsPackagesStaticGeneratedTopDataByConfiguredRoot(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "assets")) mustMkdir(t, filepath.Join(root, "topdata", "data", "tailmodel")) mustMkdir(t, filepath.Join(root, "topdata", "data", "skyboxes")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": { "name": "Test Module", "resref": "testmod", "hak_order": ["group:sow_part", "group:sow_envi"] }, "paths": { "source": "src", "assets": "assets", "build": "build" }, "generated_assets": { "topdata_2da": [ { "id": "part_topdata", "source": "topdata", "output": "{paths.cache}/generated-assets/part-topdata-2da", "include_datasets": ["tailmodel"], "package_root": "part" }, { "id": "envi_topdata", "source": "topdata", "output": "{paths.cache}/generated-assets/envi-topdata-2da", "include_datasets": ["skyboxes"], "package_root": "envi" } ] }, "haks": [ { "name": "sow_part", "priority": 1, "max_bytes": 0, "split": false, "include": ["part/**"] }, { "name": "sow_envi", "priority": 2, "max_bytes": 0, "split": false, "include": ["envi/**"] } ] } `) mustWriteFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "base.json"), `{ "output": "tailmodel.2da", "columns": ["LABEL", "MODEL", "ENVMAP"], "rows": [ {"id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"}, {"id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"} ] } `) mustWriteFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "base.json"), `{ "output": "skyboxes.2da", "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"], "rows": [ {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"}, {"id": 1, "LABEL": "Grass_Clear", "STRING_REF": "****", "CYCLICAL": "1", "DAWN": "Skyda_001", "DAY": "Sky_001", "DUSK": "Skyd_001", "NIGHT": "Skyn_001"} ] } `) 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 two HAKs, got %#v", result.HAKPaths) } raw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read haks manifest: %v", err) } manifestText := string(raw) for _, want := range []string{ `"part/tailmodel.2da"`, `"envi/skyboxes.2da"`, } { if !strings.Contains(manifestText, want) { t.Fatalf("expected generated static 2da asset %q in manifest, got:\n%s", want, manifestText) } } if strings.Contains(manifestText, `"part/skyboxes.2da"`) || strings.Contains(manifestText, `"envi/tailmodel.2da"`) { t.Fatalf("expected generated static 2da assets to follow configured package roots, got:\n%s", manifestText) } for _, generated := range []string{ filepath.Join(root, ".cache", "generated-assets", "part-topdata-2da", "tailmodel.2da"), filepath.Join(root, ".cache", "generated-assets", "envi-topdata-2da", "skyboxes.2da"), } { if _, err := os.Stat(generated); err != nil { t.Fatalf("expected generated 2da %s: %v", generated, err) } } } func TestBuildHAKsRejectsTLKBackedGeneratedParts2DAAssets(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, "topdata", "data", "parts")) 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"}, "generated_assets": { "topdata_2da": [ { "id": "parts", "source": "topdata", "output": "{paths.cache}/generated-assets/parts-2da", "include_datasets": ["parts/**"], "package_root": "part", "autogen": { "id": "parts", "mode": "parts_rows", "root": "part", "include": ["**/*.mdl"], "derive": {"kind": "trailing_numeric_suffix", "group_from": "first_path_segment"} } } ] }, "haks": [{"name": "sow_part", "priority": 1, "max_bytes": 0, "include": ["part/**"]}] } `) mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt") mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{ "output": "parts_belt.2da", "columns": ["COSTMODIFIER", "ACBONUS", "NAME"], "rows": [ {"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25", "NAME": {"tlk": {"text": "Belt"}}} ] } `) 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 || !strings.Contains(err.Error(), "2DA-only generated asset") { t.Fatalf("expected TLK-backed generated asset error, got %v", err) } } 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, 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", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "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", append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, 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 TestExtractMergesConfiguredGFFJSONFieldsAndLists(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.yaml"), ` module: name: Test Module resref: testmod paths: source: src assets: assets build: build extract: merge: gff_json: - target: module/module.ifo.json preserve_fields: - Mod_Entry_Area - Mod_Entry_X merge_lists: - field: Mod_Area_list key_field: Area_Name strategy: preserve_existing_order_append_new `) 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_Entry_Area", "type": "ResRef", "value": "area_a" }, { "label": "Mod_Entry_X", "type": "Float", "value": 1 }, { "label": "Mod_Area_list", "type": "List", "value": [ { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "area_a" } ] }, { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "area_b" } ] }, { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "area_c" } ] } ] } ] } } `) for _, area := range []string{"area_a", "area_b", "area_c"} { mustWriteFile(t, filepath.Join(root, "src", "areas", area+".are.json"), `{ "file_type": "ARE ", "file_version": "V3.2", "root": { "struct_type": 0, "fields": [] } } `) } 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_Entry_Area", "type": "ResRef", "value": "area_b" }, { "label": "Mod_Entry_X", "type": "Float", "value": 99 }, { "label": "Mod_Area_list", "type": "List", "value": [ { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "area_b" } ] }, { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "area_a" } ] }, { "struct_type": 6, "fields": [ { "label": "Area_Name", "type": "ResRef", "value": "removed_area" } ] } ] } ] } } `) if err := p.Scan(); err != nil { t.Fatalf("rescan before extract: %v", err) } if _, err := Extract(p); err != nil { t.Fatalf("extract: %v", err) } document := readGFFJSON(t, filepath.Join(root, "src", "module", "module.ifo.json")) if got, want := fieldValue(t, document.Root, "Mod_Entry_Area"), gff.ResRefValue("area_b"); got != want { t.Fatalf("expected preserved Mod_Entry_Area %#v, got %#v", want, got) } if got, want := fieldValue(t, document.Root, "Mod_Entry_X"), gff.FloatValue(99); got != want { t.Fatalf("expected preserved Mod_Entry_X %#v, got %#v", want, got) } if got, want := keyedListValues(t, document.Root, "Mod_Area_list", "Area_Name"), []string{"area_b", "area_a", "area_c"}; !slices.Equal(got, want) { t.Fatalf("expected merged area order %#v, got %#v", want, got) } } 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 readGFFJSON(t *testing.T, path string) gff.Document { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } var document gff.Document if err := json.Unmarshal(raw, &document); err != nil { t.Fatalf("parse %s: %v", path, err) } return document } func fieldValue(t *testing.T, s gff.Struct, label string) gff.Value { t.Helper() for _, field := range s.Fields { if field.Label == label { return field.Value } } t.Fatalf("field %s not found", label) return nil } func keyedListValues(t *testing.T, s gff.Struct, listLabel, keyLabel string) []string { t.Helper() list, ok := fieldValue(t, s, listLabel).(gff.ListValue) if !ok { t.Fatalf("field %s is not a list", listLabel) } values := make([]string, 0, len(list)) for _, item := range list { value := fieldValue(t, item, keyLabel) switch typed := value.(type) { case gff.ResRefValue: values = append(values, string(typed)) case gff.StringValue: values = append(values, string(typed)) default: t.Fatalf("field %s has unsupported key type %T", keyLabel, value) } } return values } func minimalGFF(t *testing.T, fileType string) []byte { t.Helper() document := gff.Document{ FileType: fileType + " ", FileVersion: "V3.2", Root: gff.Struct{Type: 0}, } var buf bytes.Buffer if err := gff.Write(&buf, document); err != nil { t.Fatalf("write %s gff: %v", fileType, err) } return buf.Bytes() } 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 TestBuildHAKsUsesExplicitMusicDatasetOutsideLegacyRoot(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate")) mustMkdir(t, filepath.Join(root, "build")) ffprobePath := filepath.Join(root, "tools", "ffprobe") ffmpegPath := filepath.Join(root, "tools", "ffmpeg") mustMkdir(t, filepath.Dir(ffprobePath)) mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Dataset Artist\",\"title\":\"Dataset Title\"}}}'\n") mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n") if err := os.Chmod(ffprobePath, 0o755); err != nil { t.Fatalf("chmod ffprobe: %v", err) } if err := os.Chmod(ffmpegPath, 0o755); err != nil { t.Fatalf("chmod ffmpeg: %v", err) } mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Assets resref: testassets paths: assets: assets build: build music: tools: ffmpeg: tools/ffmpeg ffprobe: tools/ffprobe defaults: credits_root: custom-credits datasets: westgate_audio: source: audio/westgate output: generated/music prefix: wg_ haks: - name: music priority: 1 max_bytes: 1048576 split: false include: - generated/music/** `) mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.mp3"), "source-mp3") 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 result.CreditsSummary.TrackCount != 1 { t.Fatalf("expected one music track, got %#v", result.CreditsSummary) } manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } manifestText := string(manifestRaw) if !strings.Contains(manifestText, "generated/music/wg_theme.bmu") { t.Fatalf("expected dataset output resource in manifest, got:\n%s", manifestText) } if strings.Contains(manifestText, "audio/westgate/Theme Song.mp3") { t.Fatalf("did not expect source mp3 in manifest, got:\n%s", manifestText) } creditsRaw, err := os.ReadFile(filepath.Join(root, "custom-credits", "audio", "westgate", "CREDITS.md")) if err != nil { t.Fatalf("read generated dataset credits: %v", err) } if !strings.Contains(string(creditsRaw), "Dataset Artist") || !strings.Contains(string(creditsRaw), "wg_theme.bmu") { t.Fatalf("unexpected generated credits:\n%s", string(creditsRaw)) } } func TestBuildHAKsConvertsFLACMusicSourcesByDefault(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate")) mustMkdir(t, filepath.Join(root, "build")) ffprobePath := filepath.Join(root, "tools", "ffprobe") ffmpegPath := filepath.Join(root, "tools", "ffmpeg") mustMkdir(t, filepath.Dir(ffprobePath)) mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"FLAC Artist\",\"title\":\"FLAC Title\"}}}'\n") mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n") if err := os.Chmod(ffprobePath, 0o755); err != nil { t.Fatalf("chmod ffprobe: %v", err) } if err := os.Chmod(ffmpegPath, 0o755); err != nil { t.Fatalf("chmod ffmpeg: %v", err) } mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Assets resref: testassets paths: assets: assets build: build music: tools: ffmpeg: tools/ffmpeg ffprobe: tools/ffprobe datasets: westgate_audio: source: audio/westgate output: generated/music prefix: wg_ haks: - name: music priority: 1 max_bytes: 1048576 split: false include: - generated/music/** `) mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.flac"), "source-flac") 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 result.CreditsSummary.TrackCount != 1 { t.Fatalf("expected one music track, got %#v", result.CreditsSummary) } manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } manifestText := string(manifestRaw) if !strings.Contains(manifestText, "generated/music/wg_theme.bmu") { t.Fatalf("expected generated FLAC music in manifest, got:\n%s", manifestText) } if strings.Contains(manifestText, "audio/westgate/Theme Song.flac") { t.Fatalf("did not expect source flac in manifest, got:\n%s", manifestText) } } func TestBuildHAKsUsesYAMLMusicConvertExtensionsForDiscovery(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate")) mustMkdir(t, filepath.Join(root, "build")) ffprobePath := filepath.Join(root, "tools", "ffprobe") ffmpegPath := filepath.Join(root, "tools", "ffmpeg") mustMkdir(t, filepath.Dir(ffprobePath)) mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"AAC Artist\",\"title\":\"AAC Title\"}}}'\n") mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n") if err := os.Chmod(ffprobePath, 0o755); err != nil { t.Fatalf("chmod ffprobe: %v", err) } if err := os.Chmod(ffmpegPath, 0o755); err != nil { t.Fatalf("chmod ffmpeg: %v", err) } mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Assets resref: testassets paths: assets: assets build: build music: defaults: convert_extensions: - .aac tools: ffmpeg: tools/ffmpeg ffprobe: tools/ffprobe datasets: westgate_audio: source: audio/westgate output: generated/music prefix: wg_ haks: - name: music priority: 1 max_bytes: 1048576 split: false include: - generated/music/** `) mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.aac"), "source-aac") 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 result.CreditsSummary.TrackCount != 1 { t.Fatalf("expected one music track discovered through YAML convert_extensions, got %#v", result.CreditsSummary) } } func TestPlanHAKsDoesNotTranscodeMusicSources(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate")) mustMkdir(t, filepath.Join(root, "build")) ffmpegPath := filepath.Join(root, "tools", "ffmpeg") mustMkdir(t, filepath.Dir(ffmpegPath)) mustWriteFile(t, ffmpegPath, "#!/bin/sh\necho should-not-run >&2\nexit 42\n") if err := os.Chmod(ffmpegPath, 0o755); err != nil { t.Fatalf("chmod ffmpeg: %v", err) } mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Assets resref: testassets paths: assets: assets build: build music: tools: ffmpeg: tools/ffmpeg ffprobe: tools/ffprobe datasets: westgate_audio: source: audio/westgate output: generated/music prefix: wg_ haks: - name: music priority: 1 max_bytes: 1048576 split: false include: - generated/music/** `) mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.mp3"), "source-mp3") 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 result.CreditsSummary.TrackCount != 1 { t.Fatalf("expected one planned music track, got %#v", result.CreditsSummary) } manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read manifest: %v", err) } if !strings.Contains(string(manifestRaw), "generated/music/wg_theme.bmu") { t.Fatalf("expected planned generated music in manifest, got:\n%s", string(manifestRaw)) } if _, err := os.Stat(filepath.Join(root, ".cache", "music")); !os.IsNotExist(err) { t.Fatalf("plan-only should not stage music, stat err=%v", err) } }