Files
sow-tools/internal/pipeline/build.go
T

588 lines
15 KiB
Go

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:])
}