285 lines
7.4 KiB
Go
285 lines
7.4 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"
|
|
)
|
|
|
|
type ExtractResult struct {
|
|
ModulePath string
|
|
HAKPaths []string
|
|
Written int
|
|
Overwritten int
|
|
Removed int
|
|
Skipped int
|
|
}
|
|
|
|
func Extract(p *project.Project, files ...string) (ExtractResult, error) {
|
|
var result ExtractResult
|
|
var failures []error
|
|
desired := map[string]struct{}{}
|
|
|
|
allowed := make(map[string]bool)
|
|
for _, f := range files {
|
|
allowed[f] = true
|
|
}
|
|
|
|
shouldExtractMod := len(allowed) == 0 || allowed[p.Config.Module.ResRef+".mod"]
|
|
if shouldExtractMod {
|
|
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)
|
|
}
|
|
|
|
result.ModulePath = modulePath
|
|
written, overwritten, skipped, errs := extractArchiveResources(p, archive, desired)
|
|
result.Written += written
|
|
result.Overwritten += overwritten
|
|
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 {
|
|
filename := filepath.Base(hakPath)
|
|
if len(allowed) > 0 && !allowed[filename] {
|
|
continue
|
|
}
|
|
|
|
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, overwritten, skipped, errs := extractArchiveResources(p, hakArchive, desired)
|
|
result.Written += written
|
|
result.Overwritten += overwritten
|
|
result.Skipped += skipped
|
|
failures = append(failures, errs...)
|
|
}
|
|
|
|
removed, errs := cleanupStaleFiles(p, desired)
|
|
result.Removed = removed
|
|
failures = append(failures, errs...)
|
|
|
|
if len(failures) > 0 {
|
|
return result, errors.Join(failures...)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func extractArchiveResources(p *project.Project, archive erf.Archive, desired map[string]struct{}) (int, int, int, []error) {
|
|
var failures []error
|
|
writtenCount := 0
|
|
overwrittenCount := 0
|
|
skippedCount := 0
|
|
|
|
for _, resource := range archive.Resources {
|
|
target, data, err := extractedFile(p, resource)
|
|
if err != nil {
|
|
failures = append(failures, err)
|
|
continue
|
|
}
|
|
desired[target] = struct{}{}
|
|
|
|
state, err := writeManagedFile(target, data)
|
|
if err != nil {
|
|
failures = append(failures, err)
|
|
continue
|
|
}
|
|
switch state {
|
|
case writeNew:
|
|
writtenCount++
|
|
case writeOverwritten:
|
|
overwrittenCount++
|
|
case writeSkipped:
|
|
skippedCount++
|
|
}
|
|
}
|
|
|
|
return writtenCount, overwrittenCount, 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)
|
|
}
|
|
resref := strings.ToLower(resource.Name)
|
|
|
|
switch extension {
|
|
case "nss":
|
|
return filepath.Join(p.SourceDir(), "scripts", resref+".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), resref+"."+extension+".json"), formatted, nil
|
|
default:
|
|
return filepath.Join(p.AssetsDir(), extension, resref+"."+extension), resource.Data, nil
|
|
}
|
|
}
|
|
|
|
type writeState int
|
|
|
|
const (
|
|
writeSkipped writeState = iota
|
|
writeNew
|
|
writeOverwritten
|
|
)
|
|
|
|
func writeManagedFile(path string, data []byte) (writeState, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return writeSkipped, fmt.Errorf("create parent directory for %s: %w", path, err)
|
|
}
|
|
|
|
existing, err := os.ReadFile(path)
|
|
if err == nil {
|
|
if bytes.Equal(existing, data) {
|
|
return writeSkipped, nil
|
|
}
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return writeSkipped, fmt.Errorf("overwrite %s: %w", path, err)
|
|
}
|
|
return writeOverwritten, nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return writeSkipped, fmt.Errorf("check existing file %s: %w", path, err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return writeSkipped, fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
return writeNew, nil
|
|
}
|
|
|
|
func cleanupStaleFiles(p *project.Project, desired map[string]struct{}) (int, []error) {
|
|
candidates := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles))
|
|
for _, rel := range p.Inventory.SourceFiles {
|
|
candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
|
|
}
|
|
for _, rel := range p.Inventory.ScriptFiles {
|
|
candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
|
|
}
|
|
for _, rel := range p.Inventory.AssetFiles {
|
|
candidates = append(candidates, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
|
}
|
|
|
|
removed := 0
|
|
var failures []error
|
|
for _, path := range candidates {
|
|
if _, keep := desired[path]; keep {
|
|
continue
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
failures = append(failures, fmt.Errorf("remove stale file %s: %w", path, err))
|
|
continue
|
|
}
|
|
removed++
|
|
cleanupEmptyParents(filepath.Dir(path), p.SourceDir(), p.AssetsDir())
|
|
}
|
|
return removed, failures
|
|
}
|
|
|
|
func cleanupEmptyParents(dir string, roots ...string) {
|
|
for {
|
|
if dir == "." || dir == string(filepath.Separator) {
|
|
return
|
|
}
|
|
stop := false
|
|
for _, root := range roots {
|
|
if dir == root {
|
|
stop = true
|
|
break
|
|
}
|
|
}
|
|
if stop {
|
|
return
|
|
}
|
|
if err := os.Remove(dir); err != nil {
|
|
return
|
|
}
|
|
dir = filepath.Dir(dir)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|