Initial sow-tools import

This commit is contained in:
2026-04-02 17:19:23 +02:00
commit b7f188779a
1370 changed files with 7357 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
package pipeline
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
type ApplyManifestResult struct {
ManifestPath string
ModuleSource string
HAKCount int
}
func ApplyHAKManifest(p *project.Project, manifestPath string) (ApplyManifestResult, error) {
if manifestPath == "" {
manifestPath = filepath.Join(p.BuildDir(), "haks.json")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
return ApplyManifestResult{}, fmt.Errorf("read hak manifest: %w", err)
}
var manifest BuildManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return ApplyManifestResult{}, fmt.Errorf("parse hak manifest: %w", err)
}
moduleSource := filepath.Join(p.SourceDir(), "module", "module.ifo.json")
sourceRaw, err := os.ReadFile(moduleSource)
if err != nil {
return ApplyManifestResult{}, fmt.Errorf("read module ifo source: %w", err)
}
var document gff.Document
if err := json.Unmarshal(sourceRaw, &document); err != nil {
return ApplyManifestResult{}, fmt.Errorf("parse module ifo source: %w", err)
}
setModuleHAKList(&document, manifest.ModuleHAKs)
formatted, err := json.MarshalIndent(document, "", " ")
if err != nil {
return ApplyManifestResult{}, fmt.Errorf("marshal updated module ifo: %w", err)
}
formatted = append(formatted, '\n')
if err := os.WriteFile(moduleSource, formatted, 0o644); err != nil {
return ApplyManifestResult{}, fmt.Errorf("write module ifo source: %w", err)
}
return ApplyManifestResult{
ManifestPath: manifestPath,
ModuleSource: moduleSource,
HAKCount: len(manifest.ModuleHAKs),
}, nil
}
+587
View File
@@ -0,0 +1,587 @@
package pipeline
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator"
)
type BuildResult struct {
ModulePath string
HAKPaths []string
Manifest string
Resources int
HAKAssets int
}
type BuildManifest struct {
ModuleHAKs []string `json:"module_haks"`
HAKs []BuildManifestHAK `json:"haks"`
}
type BuildManifestHAK struct {
Name string `json:"name"`
Group string `json:"group"`
Priority int `json:"priority"`
MaxBytes int64 `json:"max_bytes"`
SizeBytes int64 `json:"size_bytes"`
Assets []string `json:"assets"`
}
type assetResource struct {
Rel string
Resource erf.Resource
Size int64
}
type hakChunk struct {
Config project.HAKConfig
Index int
Name string
Assets []assetResource
Size int64
Resources []erf.Resource
}
func Build(p *project.Project) (BuildResult, error) {
moduleResult, err := BuildModule(p)
if err != nil {
return BuildResult{}, err
}
hakResult, err := BuildHAKs(p)
if err != nil {
return BuildResult{}, err
}
moduleResult.HAKPaths = hakResult.HAKPaths
moduleResult.Manifest = hakResult.Manifest
moduleResult.HAKAssets = hakResult.HAKAssets
return moduleResult, nil
}
func BuildModule(p *project.Project) (BuildResult, error) {
if err := validateForBuild(p); err != nil {
return BuildResult{}, err
}
moduleResources, err := collectModuleResources(p, nil)
if err != nil {
return BuildResult{}, err
}
outputPath := filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod")
output, err := os.Create(outputPath)
if err != nil {
return BuildResult{}, fmt.Errorf("create module archive: %w", err)
}
defer output.Close()
if err := erf.Write(output, erf.New("MOD ", moduleResources)); err != nil {
return BuildResult{}, fmt.Errorf("write module archive: %w", err)
}
return BuildResult{
ModulePath: outputPath,
Resources: len(moduleResources),
}, nil
}
func BuildHAKs(p *project.Project) (BuildResult, error) {
if err := validateForBuild(p); err != nil {
return BuildResult{}, err
}
assetResources, err := collectAssetResources(p)
if err != nil {
return BuildResult{}, err
}
if err := cleanupGeneratedHAKs(p.BuildDir(), p.Config.Module.ResRef); err != nil {
return BuildResult{}, err
}
result := BuildResult{HAKAssets: len(assetResources)}
if len(assetResources) == 0 {
return result, nil
}
chunks, err := planHAKChunks(p, assetResources)
if err != nil {
return BuildResult{}, err
}
moduleHakOrder, err := resolveModuleHAKOrder(p, chunks)
if err != nil {
return BuildResult{}, err
}
manifest := BuildManifest{
ModuleHAKs: moduleHakOrder,
HAKs: make([]BuildManifestHAK, 0, len(chunks)),
}
for _, chunk := range chunks {
hakPath := filepath.Join(p.BuildDir(), chunk.Name+".hak")
hakOutput, err := os.Create(hakPath)
if err != nil {
return BuildResult{}, fmt.Errorf("create hak archive: %w", err)
}
if err := erf.Write(hakOutput, erf.New("HAK ", chunk.Resources)); err != nil {
hakOutput.Close()
return BuildResult{}, fmt.Errorf("write hak archive: %w", err)
}
if err := hakOutput.Close(); err != nil {
return BuildResult{}, fmt.Errorf("close hak archive: %w", err)
}
result.HAKPaths = append(result.HAKPaths, hakPath)
assets := make([]string, 0, len(chunk.Assets))
for _, asset := range chunk.Assets {
assets = append(assets, asset.Rel)
}
manifest.HAKs = append(manifest.HAKs, BuildManifestHAK{
Name: chunk.Name,
Group: chunk.Config.Name,
Priority: chunk.Config.Priority,
MaxBytes: chunk.Config.MaxBytes,
SizeBytes: chunk.Size,
Assets: assets,
})
}
manifestPath := filepath.Join(p.BuildDir(), "haks.json")
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return BuildResult{}, fmt.Errorf("marshal hak manifest: %w", err)
}
manifestBytes = append(manifestBytes, '\n')
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
result.Manifest = manifestPath
return result, nil
}
func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf.Resource, error) {
var moduleResources []erf.Resource
for _, rel := range p.Inventory.SourceFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
resource, err := resourceFromJSON(abs, moduleHakOrder)
if err != nil {
return nil, err
}
moduleResources = append(moduleResources, resource)
}
for _, rel := range p.Inventory.ScriptFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
resource, err := rawResource(abs)
if err != nil {
return nil, err
}
moduleResources = append(moduleResources, resource)
}
sortResources(moduleResources)
return moduleResources, nil
}
func collectAssetResources(p *project.Project) ([]assetResource, error) {
var hakResources []assetResource
for _, rel := range p.Inventory.AssetFiles {
abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
resource, err := rawResource(abs)
if err != nil {
return nil, err
}
hakResources = append(hakResources, assetResource{
Rel: rel,
Resource: resource,
Size: erf.ArchiveSize([]erf.Resource{resource}),
})
}
slices.SortFunc(hakResources, func(a, b assetResource) int {
return compareResourceKeys(a.Resource, b.Resource)
})
return hakResources, nil
}
func sortResources(resources []erf.Resource) {
slices.SortFunc(resources, func(a, b erf.Resource) int {
return compareResourceKeys(a, b)
})
}
func compareResourceKeys(a, b erf.Resource) int {
if cmp := strings.Compare(a.Name, b.Name); cmp != 0 {
return cmp
}
if a.Type < b.Type {
return -1
}
if a.Type > b.Type {
return 1
}
return 0
}
func resourceFromJSON(path string, moduleHakOrder []string) (erf.Resource, error) {
name, extension, err := splitSourceName(path)
if err != nil {
return erf.Resource{}, err
}
resourceType, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported source extension %q", extension)
}
raw, err := os.ReadFile(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("read %s: %w", path, err)
}
var document gff.Document
if err := json.Unmarshal(raw, &document); err != nil {
return erf.Resource{}, fmt.Errorf("parse gff json %s: %w", path, err)
}
if document.FileType == "" {
document.FileType = strings.ToUpper(strings.TrimPrefix(extension, "."))
}
if document.FileVersion == "" {
document.FileVersion = "V3.2"
}
if extension == ".ifo" && name == "module" && len(moduleHakOrder) > 0 {
setModuleHAKList(&document, moduleHakOrder)
}
var buf bytes.Buffer
if err := gff.Write(&buf, document); err != nil {
return erf.Resource{}, fmt.Errorf("encode gff %s: %w", path, err)
}
return erf.Resource{
Name: name,
Type: resourceType,
Data: buf.Bytes(),
}, nil
}
func rawResource(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported resource extension %q", filepath.Ext(path))
}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
data, err := os.ReadFile(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("read %s: %w", path, err)
}
return erf.Resource{
Name: name,
Type: resourceType,
Data: data,
}, nil
}
func splitSourceName(path string) (string, string, error) {
base := filepath.Base(path)
if !strings.HasSuffix(base, ".json") {
return "", "", fmt.Errorf("expected .json source file: %s", path)
}
stem := strings.TrimSuffix(base, ".json")
extension := filepath.Ext(stem)
if extension == "" {
return "", "", fmt.Errorf("source file must include target resource extension before .json: %s", path)
}
return strings.TrimSuffix(stem, extension), strings.ToLower(extension), nil
}
func planHAKChunks(p *project.Project, assets []assetResource) ([]hakChunk, error) {
if len(assets) == 0 {
return nil, nil
}
if len(p.Config.HAKs) == 0 {
chunk := hakChunk{
Config: project.HAKConfig{
Name: p.Config.Module.ResRef,
Priority: 1,
},
Index: 1,
Name: p.Config.Module.ResRef,
Size: erf.ArchiveSize(resourceSlice(assets)),
Assets: assets,
Resources: resourceSlice(assets),
}
return []hakChunk{chunk}, nil
}
configs := make([]project.HAKConfig, len(p.Config.HAKs))
copy(configs, p.Config.HAKs)
slices.SortFunc(configs, func(a, b project.HAKConfig) int {
if a.Priority != b.Priority {
if a.Priority < b.Priority {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
grouped := make([][]assetResource, len(configs))
for _, asset := range assets {
matched := false
for index, cfg := range configs {
if matchesAnyPattern(asset.Rel, cfg.Include) {
grouped[index] = append(grouped[index], asset)
matched = true
break
}
}
if !matched {
return nil, fmt.Errorf("asset %s does not match any hak include pattern", asset.Rel)
}
}
var chunks []hakChunk
for index, cfg := range configs {
groupAssets := grouped[index]
if len(groupAssets) == 0 {
continue
}
groupChunks, err := splitHAKGroup(cfg, groupAssets)
if err != nil {
return nil, err
}
chunks = append(chunks, groupChunks...)
}
return chunks, nil
}
func splitHAKGroup(cfg project.HAKConfig, assets []assetResource) ([]hakChunk, error) {
maxBytes := cfg.MaxBytes
if maxBytes == 0 {
chunk := hakChunk{
Config: cfg,
Index: 1,
Name: cfg.Name,
Assets: assets,
Resources: resourceSlice(assets),
Size: erf.ArchiveSize(resourceSlice(assets)),
}
if err := ensureUniqueChunkResources(chunk); err != nil {
return nil, err
}
return []hakChunk{chunk}, nil
}
var chunks []hakChunk
current := hakChunk{Config: cfg, Index: 1}
for _, asset := range assets {
candidateAssets := append(append([]assetResource{}, current.Assets...), asset)
candidateResources := resourceSlice(candidateAssets)
candidateSize := erf.ArchiveSize(candidateResources)
if len(current.Assets) == 0 {
if candidateSize > maxBytes {
return nil, fmt.Errorf("asset %s alone exceeds max_bytes for hak %s", asset.Rel, cfg.Name)
}
current.Assets = candidateAssets
current.Resources = candidateResources
current.Size = candidateSize
continue
}
if candidateSize <= maxBytes {
current.Assets = candidateAssets
current.Resources = candidateResources
current.Size = candidateSize
continue
}
if !cfg.Split {
return nil, fmt.Errorf("hak %s exceeds max_bytes and split is disabled", cfg.Name)
}
current.Name = chunkName(cfg.Name, current.Index, true)
if err := ensureUniqueChunkResources(current); err != nil {
return nil, err
}
chunks = append(chunks, current)
current = hakChunk{
Config: cfg,
Index: current.Index + 1,
Assets: []assetResource{asset},
Resources: []erf.Resource{asset.Resource},
Size: erf.ArchiveSize([]erf.Resource{asset.Resource}),
}
if current.Size > maxBytes {
return nil, fmt.Errorf("asset %s alone exceeds max_bytes for hak %s", asset.Rel, cfg.Name)
}
}
if len(current.Assets) > 0 {
current.Name = chunkName(cfg.Name, current.Index, cfg.Split || len(chunks) > 0)
if err := ensureUniqueChunkResources(current); err != nil {
return nil, err
}
chunks = append(chunks, current)
}
return chunks, nil
}
func chunkName(base string, index int, split bool) string {
if !split {
return base
}
return fmt.Sprintf("%s_%02d", base, index)
}
func resourceSlice(assets []assetResource) []erf.Resource {
out := make([]erf.Resource, 0, len(assets))
for _, asset := range assets {
out = append(out, asset.Resource)
}
return out
}
func matchesAnyPattern(path string, patterns []string) bool {
for _, pattern := range patterns {
if matchPathPattern(filepath.ToSlash(path), filepath.ToSlash(pattern)) {
return true
}
}
return false
}
func matchPathPattern(path, pattern string) bool {
pathSegs := strings.Split(strings.Trim(path, "/"), "/")
patternSegs := strings.Split(strings.Trim(pattern, "/"), "/")
return matchSegments(pathSegs, patternSegs)
}
func cleanupGeneratedHAKs(buildDir, moduleResRef string) error {
paths, err := manifestHAKPaths(buildDir)
if err != nil {
return err
}
for _, path := range paths {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove previous generated hak %s: %w", path, err)
}
}
legacy := filepath.Join(buildDir, moduleResRef+".hak")
if err := os.Remove(legacy); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove legacy generated hak %s: %w", legacy, err)
}
manifest := filepath.Join(buildDir, "haks.json")
if err := os.Remove(manifest); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove previous hak manifest: %w", err)
}
return nil
}
func resolveModuleHAKOrder(p *project.Project, chunks []hakChunk) ([]string, error) {
if len(p.Config.Module.HAKOrder) == 0 {
return nil, nil
}
byGroup := map[string][]string{}
for _, chunk := range chunks {
byGroup[chunk.Config.Name] = append(byGroup[chunk.Config.Name], chunk.Name)
}
seen := map[string]struct{}{}
var ordered []string
for _, entry := range p.Config.Module.HAKOrder {
if strings.HasPrefix(entry, "group:") {
groupName := strings.TrimPrefix(entry, "group:")
names, ok := byGroup[groupName]
if !ok {
continue
}
for _, name := range names {
if _, exists := seen[name]; exists {
continue
}
ordered = append(ordered, name)
seen[name] = struct{}{}
}
continue
}
if _, exists := seen[entry]; exists {
continue
}
ordered = append(ordered, entry)
seen[entry] = struct{}{}
}
return ordered, nil
}
func setModuleHAKList(document *gff.Document, hakNames []string) {
list := make(gff.ListValue, 0, len(hakNames))
for _, name := range hakNames {
list = append(list, gff.Struct{
Type: 8,
Fields: []gff.Field{
gff.NewField("Mod_Hak", gff.StringValue(name)),
},
})
}
for index, field := range document.Root.Fields {
if field.Label == "Mod_HakList" {
document.Root.Fields[index] = gff.NewField("Mod_HakList", list)
return
}
}
document.Root.Fields = append(document.Root.Fields, gff.NewField("Mod_HakList", list))
}
func validateForBuild(p *project.Project) error {
report := validator.ValidateProject(p)
if report.HasErrors() {
return fmt.Errorf("validation failed with %d error(s)", len(report.Diagnostics))
}
return nil
}
func matchSegments(pathSegs, patternSegs []string) bool {
if len(patternSegs) == 0 {
return len(pathSegs) == 0
}
if patternSegs[0] == "**" {
if matchSegments(pathSegs, patternSegs[1:]) {
return true
}
if len(pathSegs) > 0 {
return matchSegments(pathSegs[1:], patternSegs)
}
return false
}
if len(pathSegs) == 0 {
return false
}
ok, err := filepath.Match(patternSegs[0], pathSegs[0])
if err != nil || !ok {
return false
}
return matchSegments(pathSegs[1:], patternSegs[1:])
}
+27
View File
@@ -0,0 +1,27 @@
package pipeline
import (
"fmt"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
)
func ensureUniqueChunkResources(chunk hakChunk) error {
seen := map[string]string{}
for _, asset := range chunk.Assets {
key := fmt.Sprintf("%s:%04x", asset.Resource.Name, asset.Resource.Type)
if previous, exists := seen[key]; exists {
return fmt.Errorf("resource %s from %s conflicts with %s inside generated hak %s", chunkResourceLabel(asset), asset.Rel, previous, chunk.Name)
}
seen[key] = asset.Rel
}
return nil
}
func chunkResourceLabel(asset assetResource) string {
extension, ok := erf.ExtensionForResourceType(asset.Resource.Type)
if !ok {
return asset.Resource.Name
}
return asset.Resource.Name + "." + extension
}
+245
View File
@@ -0,0 +1,245 @@
package pipeline
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
type CompareResult struct {
ModulePath string
HAKPaths []string
Checked int
}
type resourceExpectation struct {
Key string
Kind string
Bytes []byte
}
func Compare(p *project.Project) (CompareResult, error) {
modulePath := filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod")
moduleArchive, err := readArchive(modulePath)
if err != nil {
return CompareResult{}, err
}
var hakPaths []string
if len(p.Inventory.AssetFiles) > 0 {
var err error
hakPaths, err = manifestHAKPaths(p.BuildDir())
if err != nil {
return CompareResult{}, err
}
}
expected, err := expectedResources(p)
if err != nil {
return CompareResult{}, err
}
actual := archiveIndex(moduleArchive)
for _, hakPath := range hakPaths {
hakArchive, err := readArchive(hakPath)
if err != nil {
return CompareResult{}, err
}
for key, value := range archiveIndex(hakArchive) {
actual[key] = value
}
}
var diagnostics []error
checked := 0
expectedKeys := make([]string, 0, len(expected))
for key := range expected {
expectedKeys = append(expectedKeys, key)
}
slices.Sort(expectedKeys)
for _, key := range expectedKeys {
want := expected[key]
got, ok := actual[key]
if !ok {
diagnostics = append(diagnostics, fmt.Errorf("missing resource in built archives: %s", key))
continue
}
if !bytes.Equal(want.Bytes, got.Data) {
diagnostics = append(diagnostics, fmt.Errorf("resource content mismatch: %s", key))
continue
}
checked++
delete(actual, key)
}
if len(actual) > 0 {
extraKeys := make([]string, 0, len(actual))
for key := range actual {
extraKeys = append(extraKeys, key)
}
slices.Sort(extraKeys)
for _, key := range extraKeys {
diagnostics = append(diagnostics, fmt.Errorf("unexpected extra resource in built archives: %s", key))
}
}
result := CompareResult{
ModulePath: modulePath,
HAKPaths: hakPaths,
Checked: checked,
}
if len(diagnostics) > 0 {
return result, errors.Join(diagnostics...)
}
return result, nil
}
func expectedResources(p *project.Project) (map[string]resourceExpectation, error) {
out := map[string]resourceExpectation{}
for _, rel := range p.Inventory.SourceFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
name, extension, err := splitSourceName(abs)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("read %s: %w", abs, err)
}
var document gff.Document
if err := json.Unmarshal(raw, &document); err != nil {
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
}
canonical, err := json.Marshal(document)
if err != nil {
return nil, fmt.Errorf("marshal canonical gff json %s: %w", abs, err)
}
out[resourceKey(name, extension)] = resourceExpectation{
Key: resourceKey(name, extension),
Kind: "gff-json",
Bytes: canonical,
}
}
for _, rel := range p.Inventory.ScriptFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
data, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("read %s: %w", abs, err)
}
name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
out[resourceKey(name, extension)] = resourceExpectation{
Key: resourceKey(name, extension),
Kind: "raw",
Bytes: data,
}
}
for _, rel := range p.Inventory.AssetFiles {
abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
data, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("read %s: %w", abs, err)
}
name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
out[resourceKey(name, extension)] = resourceExpectation{
Key: resourceKey(name, extension),
Kind: "raw",
Bytes: data,
}
}
return out, nil
}
func readArchive(path string) (erf.Archive, error) {
input, err := os.Open(path)
if err != nil {
return erf.Archive{}, fmt.Errorf("open archive %s: %w", path, err)
}
defer input.Close()
archive, err := erf.Read(input)
if err != nil {
return erf.Archive{}, fmt.Errorf("read archive %s: %w", path, err)
}
return archive, nil
}
func archiveIndex(archive erf.Archive) map[string]erf.Resource {
out := map[string]erf.Resource{}
for _, resource := range archive.Resources {
extension, ok := erf.ExtensionForResourceType(resource.Type)
if !ok {
continue
}
key := resourceKey(resource.Name, extension)
switch extension {
case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl":
document, err := gff.Read(bytes.NewReader(resource.Data))
if err != nil {
out[key] = resource
continue
}
canonical, err := json.Marshal(document)
if err != nil {
out[key] = resource
continue
}
resource.Data = canonical
}
out[key] = resource
}
return out
}
func resourceKey(name, extension string) string {
return name + "." + strings.TrimPrefix(strings.ToLower(extension), ".")
}
func manifestHAKPaths(buildDir string) ([]string, error) {
manifestPath := filepath.Join(buildDir, "haks.json")
raw, err := os.ReadFile(manifestPath)
if err == nil {
var manifest BuildManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("parse hak manifest: %w", err)
}
paths := make([]string, 0, len(manifest.HAKs))
for _, hak := range manifest.HAKs {
paths = append(paths, filepath.Join(buildDir, hak.Name+".hak"))
}
return paths, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read hak manifest: %w", err)
}
legacy := filepath.Join(buildDir, "*.hak")
paths, err := filepath.Glob(legacy)
if err != nil {
return nil, fmt.Errorf("scan hak archives: %w", err)
}
slices.Sort(paths)
return paths, nil
}
+192
View File
@@ -0,0 +1,192 @@
package pipeline
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
type ExtractResult struct {
ModulePath string
HAKPaths []string
Written int
Skipped int
}
func Extract(p *project.Project) (ExtractResult, error) {
modulePath := filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod")
input, err := os.Open(modulePath)
if err != nil {
return ExtractResult{}, fmt.Errorf("open module archive: %w", err)
}
defer input.Close()
archive, err := erf.Read(input)
if err != nil {
return ExtractResult{}, fmt.Errorf("read module archive: %w", err)
}
var result ExtractResult
result.ModulePath = modulePath
var failures []error
written, skipped, errs := extractArchiveResources(p, archive)
result.Written += written
result.Skipped += skipped
failures = append(failures, errs...)
hakPaths, err := filepath.Glob(filepath.Join(p.BuildDir(), "*.hak"))
if err != nil {
return result, fmt.Errorf("scan hak archives: %w", err)
}
slices.Sort(hakPaths)
for _, hakPath := range hakPaths {
input, err := os.Open(hakPath)
if err != nil {
failures = append(failures, fmt.Errorf("open hak archive %s: %w", hakPath, err))
continue
}
hakArchive, err := erf.Read(input)
input.Close()
if err != nil {
failures = append(failures, fmt.Errorf("read hak archive %s: %w", hakPath, err))
continue
}
result.HAKPaths = append(result.HAKPaths, hakPath)
written, skipped, errs := extractArchiveResources(p, hakArchive)
result.Written += written
result.Skipped += skipped
failures = append(failures, errs...)
}
if len(failures) > 0 {
return result, errors.Join(failures...)
}
return result, nil
}
func extractArchiveResources(p *project.Project, archive erf.Archive) (int, int, []error) {
var failures []error
writtenCount := 0
skippedCount := 0
for _, resource := range archive.Resources {
target, data, err := extractedFile(p, resource)
if err != nil {
failures = append(failures, err)
continue
}
written, err := writeSafely(target, data)
if err != nil {
failures = append(failures, err)
continue
}
if written {
writtenCount++
} else {
skippedCount++
}
}
return writtenCount, skippedCount, failures
}
func extractedFile(p *project.Project, resource erf.Resource) (string, []byte, error) {
extension, ok := erf.ExtensionForResourceType(resource.Type)
if !ok {
return "", nil, fmt.Errorf("unsupported resource type 0x%04X for %s", resource.Type, resource.Name)
}
switch extension {
case "nss":
return filepath.Join(p.SourceDir(), "scripts", resource.Name+".nss"), resource.Data, nil
case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl":
document, err := gff.Read(bytes.NewReader(resource.Data))
if err != nil {
return "", nil, fmt.Errorf("decode gff %s.%s: %w", resource.Name, extension, err)
}
formatted, err := json.MarshalIndent(document, "", " ")
if err != nil {
return "", nil, fmt.Errorf("marshal json %s.%s: %w", resource.Name, extension, err)
}
formatted = append(formatted, '\n')
return filepath.Join(p.SourceDir(), sourceSubdir(extension), resource.Name+"."+extension+".json"), formatted, nil
default:
return filepath.Join(p.AssetsDir(), extension, resource.Name+"."+extension), resource.Data, nil
}
}
func writeSafely(path string, data []byte) (bool, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return false, fmt.Errorf("create parent directory for %s: %w", path, err)
}
existing, err := os.ReadFile(path)
if err == nil {
if bytes.Equal(existing, data) {
return false, nil
}
return false, fmt.Errorf("refusing to overwrite existing file with different contents: %s", path)
}
if !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("check existing file %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return false, fmt.Errorf("write %s: %w", path, err)
}
return true, nil
}
func sourceSubdir(extension string) string {
switch strings.ToLower(extension) {
case "are":
return "areas"
case "dlg":
return "dialogs"
case "fac":
return "factions"
case "gic":
return "instance"
case "git":
return "instance"
case "ifo":
return "module"
case "itp":
return "palettes"
case "jrl":
return "journal"
case "utc":
return filepath.Join("blueprints", "creatures")
case "utd":
return filepath.Join("blueprints", "doors")
case "ute":
return filepath.Join("blueprints", "encounters")
case "uti":
return filepath.Join("blueprints", "items")
case "utm":
return filepath.Join("blueprints", "merchants")
case "utp":
return filepath.Join("blueprints", "placeables")
case "uts":
return filepath.Join("blueprints", "sounds")
case "utt":
return filepath.Join("blueprints", "triggers")
case "utw":
return filepath.Join("blueprints", "waypoints")
default:
return extension
}
}
+673
View File
@@ -0,0 +1,673 @@
package pipeline
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func TestBuildThenExtract(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src"))
mustMkdir(t, filepath.Join(root, "assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
}
}
`)
mustMkdir(t, filepath.Join(root, "src", "module"))
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{
"label": "Mod_Name",
"type": "CExoString",
"value": "Test Module"
}
]
}
}
`)
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
buildResult, err := Build(p)
if err != nil {
t.Fatalf("build: %v", err)
}
if buildResult.Resources != 1 {
t.Fatalf("expected 1 resource, got %d", buildResult.Resources)
}
if err := os.Remove(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil {
t.Fatalf("remove source file: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("rescan: %v", err)
}
extractResult, err := Extract(p)
if err != nil {
t.Fatalf("extract: %v", err)
}
if extractResult.Written != 1 {
t.Fatalf("expected 1 extracted file, got %d", extractResult.Written)
}
if _, err := os.Stat(filepath.Join(root, "src", "module", "module.ifo.json")); err != nil {
t.Fatalf("expected extracted module file: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan after extract: %v", err)
}
compareResult, err := Compare(p)
if err != nil {
t.Fatalf("compare: %v", err)
}
if compareResult.Checked != 1 {
t.Fatalf("expected 1 compared resource, got %d", compareResult.Checked)
}
}
func TestExtractReadsHAKAssets(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src"))
mustMkdir(t, filepath.Join(root, "assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
}
}
`)
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
modFile, err := os.Create(filepath.Join(root, "build", "testmod.mod"))
if err != nil {
t.Fatalf("create mod: %v", err)
}
if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil {
t.Fatalf("write mod: %v", err)
}
if err := modFile.Close(); err != nil {
t.Fatalf("close mod: %v", err)
}
hakFile, err := os.Create(filepath.Join(root, "build", "vfx.hak"))
if err != nil {
t.Fatalf("create hak: %v", err)
}
if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{
{Name: "test_vfx", Type: 0x07D2, Data: []byte("mdl-data")},
{Name: "test_icon", Type: 0x0006, Data: []byte("plt-data")},
})); err != nil {
t.Fatalf("write hak: %v", err)
}
if err := hakFile.Close(); err != nil {
t.Fatalf("close hak: %v", err)
}
result, err := Extract(p)
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(result.HAKPaths) != 1 {
t.Fatalf("expected 1 hak path, got %d", len(result.HAKPaths))
}
if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "test_vfx.mdl")); err != nil {
t.Fatalf("expected extracted mdl asset: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "assets", "plt", "test_icon.plt")); err != nil {
t.Fatalf("expected extracted plt asset: %v", err)
}
}
func 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 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 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 mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
}
func mustWriteFile(t *testing.T, path, data string) {
t.Helper()
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestBuildHAKsFailsForDuplicateResourcesInSameHAK(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src"))
mustMkdir(t, filepath.Join(root, "assets", "core", "a"))
mustMkdir(t, filepath.Join(root, "assets", "core", "b"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"haks": [
{
"name": "core",
"priority": 1,
"max_bytes": 0,
"split": false,
"include": ["core/**"]
}
]
}
`)
mustWriteFile(t, filepath.Join(root, "assets", "core", "a", "shared.mdl"), "one")
mustWriteFile(t, filepath.Join(root, "assets", "core", "b", "shared.mdl"), "two")
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
_, err = BuildHAKs(p)
if err == nil {
t.Fatal("expected duplicate same-hak build error")
}
if !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("unexpected error: %v", err)
}
}