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