Compile referenced NWScript during module builds
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user