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 } }