diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index 115f446..ea1af83 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" + "runtime" "slices" "strings" @@ -222,10 +224,190 @@ func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf. moduleResources = append(moduleResources, resource) } + compiledScripts, err := compileReferencedScripts(p) + if err != nil { + return nil, err + } + moduleResources = append(moduleResources, compiledScripts...) + sortResources(moduleResources) return moduleResources, nil } +func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) { + referenced, err := referencedScriptResrefs(p) + if err != nil { + return nil, err + } + if len(referenced) == 0 { + return nil, nil + } + + scriptSources := make(map[string]string, len(p.Inventory.ScriptFiles)) + for _, rel := range p.Inventory.ScriptFiles { + resref := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) + scriptSources[resref] = filepath.Join(p.SourceDir(), filepath.FromSlash(rel)) + } + + compiler, err := resolveScriptCompiler(p) + if err != nil { + return nil, err + } + + tmpDir, err := os.MkdirTemp("", "sow-script-compile-*") + if err != nil { + return nil, fmt.Errorf("create script compiler temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + resources := make([]erf.Resource, 0, len(referenced)) + scriptsDir := filepath.Join(p.SourceDir(), "scripts") + for _, resref := range referenced { + sourcePath, ok := scriptSources[resref] + if !ok { + continue + } + outputPath := filepath.Join(tmpDir, resref+".ncs") + cmd := exec.Command(compiler, "--dirs", scriptsDir, "-o", outputPath, sourcePath) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return nil, fmt.Errorf("compile script %s: %s", filepath.Base(sourcePath), message) + } + resource, err := rawResource(outputPath) + if err != nil { + return nil, fmt.Errorf("load compiled script %s: %w", filepath.Base(outputPath), err) + } + resources = append(resources, resource) + } + + return resources, nil +} + +func referencedScriptResrefs(p *project.Project) ([]string, error) { + referenced := map[string]struct{}{} + for _, rel := range p.Inventory.SourceFiles { + abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel)) + document, _, _, err := validatorLoadDocument(abs) + if err != nil { + return nil, err + } + validatorWalkFields(document.Root, func(fieldLabel, value string) { + if !validatorIsScriptField(fieldLabel) { + return + } + value = strings.TrimSpace(value) + if value == "" { + return + } + referenced[strings.ToLower(value)] = struct{}{} + }) + } + + resrefs := make([]string, 0, len(referenced)) + for resref := range referenced { + resrefs = append(resrefs, resref) + } + slices.Sort(resrefs) + return resrefs, nil +} + +func resolveScriptCompiler(p *project.Project) (string, error) { + if configured := strings.TrimSpace(os.Getenv("SOW_NWN_SCRIPT_COMPILER")); configured != "" { + return configured, nil + } + + candidates := []string{ + filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp"), + filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp.exe"), + filepath.Join(p.Root, "tools", "nwn_script_comp"), + filepath.Join(p.Root, "tools", "nwn_script_comp.exe"), + } + if runtime.GOOS == "windows" { + candidates = []string{ + filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp.exe"), + filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp"), + filepath.Join(p.Root, "tools", "nwn_script_comp.exe"), + filepath.Join(p.Root, "tools", "nwn_script_comp"), + } + } + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + + if compiler, err := exec.LookPath("nwn_script_comp"); err == nil { + return compiler, nil + } + if runtime.GOOS == "windows" { + if compiler, err := exec.LookPath("nwn_script_comp.exe"); err == nil { + return compiler, nil + } + } + + return "", errors.New("script compiler not found; install nwn_script_comp into ./tools or set SOW_NWN_SCRIPT_COMPILER") +} + +func validatorLoadDocument(path string) (gff.Document, string, string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return gff.Document{}, "", "", fmt.Errorf("read %s: %w", path, err) + } + + stem := strings.TrimSuffix(filepath.Base(path), ".json") + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(stem)), ".") + if extension == "" { + return gff.Document{}, "", "", fmt.Errorf("missing resource extension before .json: %s", path) + } + resref := strings.ToLower(strings.TrimSuffix(stem, "."+extension)) + + var document gff.Document + if err := json.Unmarshal(raw, &document); err != nil { + return gff.Document{}, "", "", fmt.Errorf("parse %s: %w", path, err) + } + return document, resref, extension, nil +} + +func validatorWalkFields(root gff.Struct, visit func(string, string)) { + walkScriptFields(root, visit) +} + +func validatorIsScriptField(label string) bool { + return strings.HasPrefix(label, "On") || strings.HasSuffix(label, "Script") || strings.Contains(label, "Script") +} + +func walkScriptFields(s gff.Struct, visit func(string, string)) { + for _, field := range s.Fields { + if value, ok := fieldStringValueForScripts(field.Value); ok { + visit(field.Label, value) + } + switch typed := field.Value.(type) { + case gff.Struct: + walkScriptFields(typed, visit) + case gff.ListValue: + for _, item := range typed { + walkScriptFields(item, visit) + } + } + } +} + +func fieldStringValueForScripts(value gff.Value) (string, bool) { + switch typed := value.(type) { + case gff.StringValue: + return string(typed), true + case gff.ResRefValue: + return string(typed), true + default: + return "", false + } +} + func collectAssetResources(p *project.Project) ([]assetResource, error) { var hakResources []assetResource diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index bb32b42..3d6e321 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -279,6 +279,146 @@ func TestBuildSplitsConfiguredHAKs(t *testing.T) { } } +func TestBuildModuleCompilesReferencedScripts(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "src", "placeables")) + mustMkdir(t, filepath.Join(root, "src", "scripts")) + mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod" + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + } +} +`) + + mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Mod_Name", + "type": "CExoString", + "value": "Test Module" + } + ] + } +} +`) + + mustWriteFile(t, filepath.Join(root, "src", "placeables", "thing.utp.json"), `{ + "file_type": "UTP ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "OnUsed", + "type": "ResRef", + "value": "use_thing" + } + ] + } +} +`) + + mustWriteFile(t, filepath.Join(root, "src", "scripts", "use_thing.nss"), `void main() {}`) + + compiler := filepath.Join(root, "fake-compiler.sh") + mustWriteFile(t, compiler, `#!/bin/sh +set -eu +out="" +src="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + --dirs) + shift 2 + ;; + *) + src="$1" + shift + ;; + esac +done +[ -n "$out" ] +[ -n "$src" ] +printf 'compiled:%s\n' "$(basename "$src")" > "$out" +`) + if err := os.Chmod(compiler, 0o755); err != nil { + t.Fatalf("chmod fake compiler: %v", err) + } + t.Setenv("SOW_NWN_SCRIPT_COMPILER", compiler) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + if err := p.ValidateLayout(); err != nil { + t.Fatalf("validate layout: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("scan: %v", err) + } + + result, err := BuildModule(p) + if err != nil { + t.Fatalf("build module: %v", err) + } + if result.Resources != 4 { + t.Fatalf("expected 4 resources, got %d", result.Resources) + } + + fh, err := os.Open(result.ModulePath) + if err != nil { + t.Fatalf("open built module: %v", err) + } + defer fh.Close() + archive, err := erf.Read(fh) + if err != nil { + t.Fatalf("read built module: %v", err) + } + + foundSource := false + foundCompiled := false + for _, resource := range archive.Resources { + if resource.Name != "use_thing" { + continue + } + if extension, ok := erf.ExtensionForResourceType(resource.Type); ok { + switch extension { + case "nss": + foundSource = true + case "ncs": + foundCompiled = true + if !strings.Contains(string(resource.Data), "compiled:use_thing.nss") { + t.Fatalf("unexpected compiled payload: %q", string(resource.Data)) + } + } + } + } + + if !foundSource { + t.Fatalf("expected source script resource in archive") + } + if !foundCompiled { + t.Fatalf("expected compiled script resource in archive") + } +} + func TestBuildOrdersMultipleHAKGroups(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src"))