Initial sow-tools import
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user