Compile referenced NWScript during module builds

This commit is contained in:
2026-04-02 22:04:58 +02:00
parent 2f3bdc73d2
commit 38ee1e21e0
2 changed files with 322 additions and 0 deletions
+182
View File
@@ -6,7 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"slices" "slices"
"strings" "strings"
@@ -222,10 +224,190 @@ func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf.
moduleResources = append(moduleResources, resource) moduleResources = append(moduleResources, resource)
} }
compiledScripts, err := compileReferencedScripts(p)
if err != nil {
return nil, err
}
moduleResources = append(moduleResources, compiledScripts...)
sortResources(moduleResources) sortResources(moduleResources)
return moduleResources, nil 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) { func collectAssetResources(p *project.Project) ([]assetResource, error) {
var hakResources []assetResource var hakResources []assetResource
+140
View File
@@ -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) { func TestBuildOrdersMultipleHAKGroups(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src")) mustMkdir(t, filepath.Join(root, "src"))