Move Generation Destinations

This commit is contained in:
2026-04-18 10:24:34 +02:00
parent 897776f432
commit 6f6bf7080a
10 changed files with 166 additions and 91 deletions
+2 -2
View File
@@ -70,12 +70,12 @@ var commands = []command{
},
{
name: "build-topdata",
description: "Build canonical topdata outputs into topdata/assets/2da and build/sow_tlk.tlk, then package sow_top.hak.",
description: "Build canonical topdata outputs into .cache/topdata, then package sow_top.hak and sow_tlk.tlk.",
run: runBuildTopData,
},
{
name: "build-top-package",
description: "Build sow_top.hak and sow_tlk.tlk from native topdata output and topdata/assets.",
description: "Build sow_top.hak and sow_tlk.tlk from cached native topdata output and authored topdata/assets.",
run: runBuildTopPackage,
},
{
+11 -5
View File
@@ -467,11 +467,13 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
return nil, err
}
tmpDir, err := os.MkdirTemp("", "sow-script-compile-*")
if err != nil {
return nil, fmt.Errorf("create script compiler temp dir: %w", err)
cacheDir := compiledScriptCacheDir(p)
if err := os.RemoveAll(cacheDir); err != nil {
return nil, fmt.Errorf("clear script compiler cache dir: %w", err)
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return nil, fmt.Errorf("create script compiler cache dir: %w", err)
}
defer os.RemoveAll(tmpDir)
resources := make([]erf.Resource, 0, len(referenced))
scriptsDir := filepath.Join(p.SourceDir(), "scripts")
@@ -480,7 +482,7 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
if !ok {
continue
}
outputPath := filepath.Join(tmpDir, resref+".ncs")
outputPath := filepath.Join(cacheDir, resref+".ncs")
cmd := exec.Command(compiler, "--dirs", scriptsDir, "-o", outputPath, sourcePath)
cmd.Env = compilerEnv
output, err := cmd.CombinedOutput()
@@ -501,6 +503,10 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
return resources, nil
}
func compiledScriptCacheDir(p *project.Project) string {
return filepath.Join(p.Root, ".cache", "ncs")
}
func referencedScriptResrefs(p *project.Project) ([]string, error) {
referenced := map[string]struct{}{}
for _, rel := range p.Inventory.SourceFiles {
+8 -2
View File
@@ -289,7 +289,7 @@ func TestBuildModuleCompilesReferencedScripts(t *testing.T) {
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, ".nwn-assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
@@ -299,7 +299,7 @@ func TestBuildModuleCompilesReferencedScripts(t *testing.T) {
},
"paths": {
"source": "src",
"assets": "assets",
"assets": ".nwn-assets",
"build": "build"
}
}
@@ -439,6 +439,12 @@ printf 'compiled:%s\nNWN_ROOT=%s\nNWN_HOME=%s\nNWN_USER_DIRECTORY=%s\n' "$(basen
if _, err := os.Stat(userDir); err != nil {
t.Fatalf("expected build to prepare NWN user directory: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); err != nil {
t.Fatalf("expected compiled script in .cache/ncs: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".nwn-assets", "ncs", "use_thing.ncs")); !os.IsNotExist(err) {
t.Fatalf("expected no compiled script in .nwn-assets/ncs, got err=%v", err)
}
compareResult, err := Compare(p)
if err != nil {
+16 -1
View File
@@ -145,6 +145,12 @@ func (p *Project) ValidateLayout() error {
} {
info, err := os.Stat(path)
if err != nil {
if label == "paths.assets" && errors.Is(err, os.ErrNotExist) && p.isCachePath(path) {
if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
failures = append(failures, fmt.Errorf("%s: create cache directory %s: %w", label, path, mkErr))
}
continue
}
failures = append(failures, fmt.Errorf("%s: %w", label, err))
continue
}
@@ -160,6 +166,15 @@ func (p *Project) ValidateLayout() error {
return nil
}
func (p *Project) isCachePath(path string) bool {
rel, err := filepath.Rel(p.Root, path)
if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return false
}
parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
return len(parts) > 0 && parts[0] == ".cache"
}
func (p *Project) Scan() error {
sourceFiles, sourceExts, err := scanDir(p.SourceDir(), func(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
@@ -334,7 +349,7 @@ func defaultConfig() Config {
Build: "build",
},
TopData: TopDataConfig{
Build: "build/topdata",
Build: ".cache/topdata",
},
}
}
+23
View File
@@ -82,6 +82,29 @@ func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
}
}
func TestValidateLayoutCreatesMissingCacheAssetsDir(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: ".cache/module-assets", Build: "build"},
},
}
if err := proj.ValidateLayout(); err != nil {
t.Fatalf("ValidateLayout returned error: %v", err)
}
if info, err := os.Stat(filepath.Join(root, ".cache", "module-assets")); err != nil {
t.Fatalf("expected cache assets dir to be created: %v", err)
} else if !info.IsDir() {
t.Fatalf("expected cache assets path to be a directory")
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
+2 -2
View File
@@ -162,8 +162,8 @@ func BuildNativeWithOptions(p *project.Project, opts NativeBuildOptions, progres
var prebuiltWiki *wikiResult
if opts.BuildWiki {
if newestWikiSource, _, err := newestRelevantWikiSource(p.TopDataSourceDir()); err == nil && !newestWikiSource.IsZero() {
statePath := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiStateFileName)
outputDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiPagesDirName)
statePath := wikiOutputStatePath(p)
outputDir := wikiOutputPagesDir(p)
if stateInfo, err := os.Stat(statePath); err == nil && !newestWikiSource.After(stateInfo.ModTime()) {
if outputInfo, err := os.Stat(outputDir); err == nil && outputInfo.IsDir() {
if state, err := loadWikiState(statePath); err == nil {
+75 -54
View File
@@ -22,26 +22,35 @@ const (
)
func compiled2DAOutputDir(p *project.Project) string {
if useDedicatedTopDataBuildDir(p) {
return filepath.Join(p.TopDataBuildDir(), "2da")
}
return filepath.Join(p.TopDataSourceDir(), "assets", "2da")
return filepath.Join(p.TopDataBuildDir(), "2da")
}
func compiledTLKOutputPath(p *project.Project) string {
if useDedicatedTopDataBuildDir(p) {
return filepath.Join(p.TopDataBuildDir(), "tlk", defaultTLKName)
}
return filepath.Join(p.BuildDir(), defaultTLKName)
return filepath.Join(p.TopDataBuildDir(), "tlk", defaultTLKName)
}
func compiledTLKOutputDir(p *project.Project) string {
return filepath.Dir(compiledTLKOutputPath(p))
}
func useDedicatedTopDataBuildDir(p *project.Project) bool {
defaultBuildDir := filepath.Join(p.BuildDir(), "topdata")
return p.TopDataBuildDir() != defaultBuildDir
func wikiOutputRootDir(p *project.Project) string {
return filepath.Join(p.TopDataBuildDir(), wikiRootDirName)
}
func wikiOutputPagesDir(p *project.Project) string {
return filepath.Join(wikiOutputRootDir(p), wikiPagesDirName)
}
func wikiOutputStatePath(p *project.Project) string {
return filepath.Join(wikiOutputRootDir(p), wikiStateFileName)
}
func legacyCompiled2DAOutputDir(p *project.Project) string {
return filepath.Join(p.TopDataSourceDir(), "assets", "2da")
}
func legacyWikiOutputRootDir(p *project.Project) string {
return filepath.Join(p.TopDataSourceDir(), legacyWikiRootDirName)
}
func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) {
@@ -172,15 +181,20 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
assetFiles := 0
assetsDir := filepath.Join(p.TopDataSourceDir(), "assets")
skipDir := compiled2DAOutputDir(p)
skipDirs := map[string]struct{}{
filepath.Clean(compiled2DADir): {},
filepath.Clean(legacyCompiled2DAOutputDir(p)): {},
}
info, err := os.Stat(assetsDir)
if err == nil && info.IsDir() {
err = filepath.WalkDir(assetsDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() && path == skipDir {
return filepath.SkipDir
if d.IsDir() {
if _, ok := skipDirs[filepath.Clean(path)]; ok {
return filepath.SkipDir
}
}
if d.IsDir() {
return nil
@@ -220,6 +234,51 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
return resources, assetFiles, nil
}
func shouldSkipTopDataSourceDir(path string, skipDirs map[string]struct{}) bool {
_, ok := skipDirs[filepath.Clean(path)]
return ok
}
func topDataSourceSkipDirs(p *project.Project) map[string]struct{} {
return map[string]struct{}{
filepath.Clean(filepath.Join(p.TopDataSourceDir(), "templates")): {},
filepath.Clean(legacyWikiOutputRootDir(p)): {},
filepath.Clean(legacyCompiled2DAOutputDir(p)): {},
filepath.Clean(wikiOutputRootDir(p)): {},
}
}
func newestTopDataSource(p *project.Project) (time.Time, string, error) {
newest := time.Time{}
newestPath := ""
skipDirs := topDataSourceSkipDirs(p)
sourceDir := p.TopDataSourceDir()
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() && shouldSkipTopDataSourceDir(path, skipDirs) {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if newest.IsZero() || info.ModTime().After(newest) {
newest = info.ModTime()
newestPath = path
}
return nil
})
if err != nil {
return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err)
}
return newest, newestPath, nil
}
func topPackageResourceFromPath(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
@@ -335,7 +394,7 @@ func packagedBuildResult(p *project.Project) (BuildResult, error) {
return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
}
newestSource, newestPath, err := newestTopDataSource(p.TopDataSourceDir())
newestSource, newestPath, err := newestTopDataSource(p)
if err != nil {
return BuildResult{}, err
}
@@ -390,7 +449,7 @@ func currentTopPackageResult(p *project.Project) (PackageResult, bool, error) {
return PackageResult{}, false, fmt.Errorf("stat top package tlk %s: %w", outputTLK, err)
}
newestSource, _, err := newestTopDataSource(p.TopDataSourceDir())
newestSource, _, err := newestTopDataSource(p)
if err != nil {
return PackageResult{}, false, err
}
@@ -481,41 +540,3 @@ func countCompiledFiles(dir, extension string) (int, time.Time, error) {
}
return count, oldest, nil
}
func newestTopDataSource(sourceDir string) (time.Time, string, error) {
newest := time.Time{}
newestPath := ""
templatesDir := filepath.Join(sourceDir, "templates")
wikiDir := filepath.Join(sourceDir, wikiRootDirName)
generated2DADir := filepath.Join(sourceDir, "assets", "2da")
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() && path == templatesDir {
return filepath.SkipDir
}
if d.IsDir() && path == wikiDir {
return filepath.SkipDir
}
if d.IsDir() && path == generated2DADir {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if newest.IsZero() || info.ModTime().After(newest) {
newest = info.ModTime()
newestPath = path
}
return nil
})
if err != nil {
return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err)
}
return newest, newestPath, nil
}
+8 -5
View File
@@ -4030,12 +4030,15 @@ func TestBuildNativeRemovesStaleTopdataOutputs(t *testing.T) {
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
if got, want := result.Output2DADir, filepath.Join(root, ".cache", "topdata", "2da"); got != want {
t.Fatalf("unexpected cached 2da output dir: got %q want %q", got, want)
}
if _, err := os.Stat(filepath.Join(result.Output2DADir, "stale.2da")); !os.IsNotExist(err) {
t.Fatalf("expected stale topdata output to be removed, stat err=%v", err)
t.Fatalf("expected stale legacy topdata output to be ignored, stat err=%v", err)
}
}
func TestBuildNativeWritesCompiledOutputsToAssetsAndBuild(t *testing.T) {
func TestBuildNativeWritesCompiledOutputsToCache(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
@@ -4053,10 +4056,10 @@ func TestBuildNativeWritesCompiledOutputsToAssetsAndBuild(t *testing.T) {
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
if got, want := result.Output2DADir, filepath.Join(root, "topdata", "assets", "2da"); got != want {
if got, want := result.Output2DADir, filepath.Join(root, ".cache", "topdata", "2da"); got != want {
t.Fatalf("unexpected 2da output dir: got %q want %q", got, want)
}
if got, want := result.OutputTLKDir, filepath.Join(root, "build"); got != want {
if got, want := result.OutputTLKDir, filepath.Join(root, ".cache", "topdata", "tlk"); got != want {
t.Fatalf("unexpected tlk output dir: got %q want %q", got, want)
}
if _, err := os.Stat(filepath.Join(result.Output2DADir, "repadjust.2da")); err != nil {
@@ -9522,7 +9525,7 @@ func testProject(root string) *project.Project {
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: project.TopDataConfig{
Source: "topdata",
Build: "build/topdata",
Build: ".cache/topdata",
ReferenceBuilder: "reference",
Assets: ".",
},
+13 -12
View File
@@ -17,15 +17,16 @@ import (
)
const (
wikiStatusNew = "new"
wikiStatusModified = "modified"
wikiStatusVanilla = "vanilla"
wikiRootDirName = ".wiki"
wikiPagesDirName = "pages"
wikiStateFileName = "state.json"
wikiGeneratorVersion = "native-v1"
wikiGeneratedStatus = "generated"
wikiSkippedStatus = "skipped"
wikiStatusNew = "new"
wikiStatusModified = "modified"
wikiStatusVanilla = "vanilla"
wikiRootDirName = "wiki"
legacyWikiRootDirName = ".wiki"
wikiPagesDirName = "pages"
wikiStateFileName = "state.json"
wikiGeneratorVersion = "native-v1"
wikiGeneratedStatus = "generated"
wikiSkippedStatus = "skipped"
)
var (
@@ -83,9 +84,9 @@ func buildWiki(p *project.Project, nativeResult BuildResult, progress func(strin
if progress == nil {
progress = func(string) {}
}
rootDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName)
outputDir := filepath.Join(rootDir, wikiPagesDirName)
statePath := filepath.Join(rootDir, wikiStateFileName)
rootDir := wikiOutputRootDir(p)
outputDir := wikiOutputPagesDir(p)
statePath := wikiOutputStatePath(p)
digest, err := computeWikiSourceDigest(p.TopDataSourceDir())
if err != nil {
+8 -8
View File
@@ -42,7 +42,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
if result.WikiPages != 1 {
t.Fatalf("expected one generated entity page, got %d", result.WikiPages)
}
pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt")
pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt")
got, err := os.ReadFile(pagePath)
if err != nil {
t.Fatalf("read generated page: %v", err)
@@ -54,7 +54,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
if !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, " * Climb") {
t.Fatalf("expected description to be rendered into wiki page:\n%s", text)
}
statusPath := filepath.Join(root, "topdata", ".wiki", "pages", "meta", "wikistatus.txt")
statusPath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "meta", "wikistatus.txt")
statusRaw, err := os.ReadFile(statusPath)
if err != nil {
t.Fatalf("read status page: %v", err)
@@ -62,10 +62,10 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
if !strings.Contains(string(statusRaw), "**Vanilla:** 1\\\\") {
t.Fatalf("expected wiki status summary to count vanilla page:\n%s", string(statusRaw))
}
if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); err != nil {
if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); err != nil {
t.Fatalf("expected state file after first build: %v", err)
}
stateBefore, err := loadWikiState(filepath.Join(root, "topdata", ".wiki", "state.json"))
stateBefore, err := loadWikiState(filepath.Join(root, ".cache", "topdata", "wiki", "state.json"))
if err != nil {
t.Fatalf("read state before second build: %v", err)
}
@@ -120,7 +120,7 @@ func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) {
if result.WikiOutputDir != "" || result.WikiPages != 0 {
t.Fatalf("expected wiki generation to be skipped by default, got dir=%q pages=%d", result.WikiOutputDir, result.WikiPages)
}
if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); !os.IsNotExist(err) {
t.Fatalf("expected no wiki state without explicit wiki build, got %v", err)
}
@@ -131,7 +131,7 @@ func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) {
if result.WikiPages != 1 {
t.Fatalf("expected one generated wiki page when requested, got %d", result.WikiPages)
}
if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); err != nil {
if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); err != nil {
t.Fatalf("expected wiki state after explicit wiki build: %v", err)
}
}
@@ -189,7 +189,7 @@ func TestBuildNativeRegeneratesWikiWhenTLKTextChanges(t *testing.T) {
if _, err := BuildNative(proj, nil); err != nil {
t.Fatalf("BuildNative after text change failed: %v", err)
}
pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt")
pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt")
got, err := os.ReadFile(pagePath)
if err != nil {
t.Fatalf("read regenerated page: %v", err)
@@ -230,7 +230,7 @@ func TestBuildPackageIgnoresWikiSourcesForFreshness(t *testing.T) {
t.Fatalf("BuildNative failed: %v", err)
}
pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt")
pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt")
newTime := time.Now().Add(2 * time.Second)
if err := os.Chtimes(pagePath, newTime, newTime); err != nil {
t.Fatalf("touch wiki page: %v", err)