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 } 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 ", resourceSlice(chunk.Assets))); 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 := pathResource(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: strings.ToLower(name), Type: resourceType, Data: buf.Bytes(), }, nil } func pathResource(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.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) info, err := os.Stat(path) if err != nil { return erf.Resource{}, fmt.Errorf("stat %s: %w", path, err) } return erf.Resource{ Name: name, Type: resourceType, SourcePath: path, Size: info.Size(), }, 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.ToLower(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: strings.ToLower(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.ToLower(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, } 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, 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.Size = candidateSize continue } if candidateSize <= maxBytes { current.Assets = candidateAssets 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}, 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:]) }