Configuration hardening
This commit is contained in:
@@ -25,6 +25,8 @@ This audit records the current repository-configuration authority model for
|
|||||||
- `build.keep_existing_haks`
|
- `build.keep_existing_haks`
|
||||||
- `inventory.source_extensions`, `inventory.asset_extensions`,
|
- `inventory.source_extensions`, `inventory.asset_extensions`,
|
||||||
`inventory.source_json_pattern`
|
`inventory.source_json_pattern`
|
||||||
|
- `validation.profile`, `validation.builtin_script_prefixes`, and
|
||||||
|
`validation.required_fields`
|
||||||
- `scripts.source_dir`, `scripts.cache`, `scripts.compiler`
|
- `scripts.source_dir`, `scripts.cache`, `scripts.compiler`
|
||||||
- `extract.layout`, `extract.hak_discovery`, `extract.cleanup_stale`
|
- `extract.layout`, `extract.hak_discovery`, `extract.cleanup_stale`
|
||||||
- `topdata.compiled_2da_dir`, `topdata.compiled_tlk`, package names, and wiki
|
- `topdata.compiled_2da_dir`, `topdata.compiled_tlk`, package names, and wiki
|
||||||
@@ -42,9 +44,8 @@ repository-specific YAML:
|
|||||||
- Supported autogen algorithms: `parts_rows`, `head_visualeffects`,
|
- Supported autogen algorithms: `parts_rows`, `head_visualeffects`,
|
||||||
`trailing_numeric_suffix`, `model_stem`, and `first_path_segment`.
|
`trailing_numeric_suffix`, `model_stem`, and `first_path_segment`.
|
||||||
- Native topdata dataset internals and generated JSON dataset formats.
|
- Native topdata dataset internals and generated JSON dataset formats.
|
||||||
- NWN validator semantics for required module fields and resource-reference
|
- NWN validator resource-reference checks. These are candidates for future
|
||||||
checks. These are candidates for future validation profiles, but are not
|
validation profiles, but are not repository-specific defaults.
|
||||||
repository-specific defaults.
|
|
||||||
|
|
||||||
## Migration Checklist
|
## Migration Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,23 @@ music:
|
|||||||
envi/music/westgate: mus_wg_
|
envi/music/westgate: mus_wg_
|
||||||
|
|
||||||
# Explicit dataset config (replaces prefixes above).
|
# Explicit dataset config (replaces prefixes above).
|
||||||
|
tools:
|
||||||
|
ffmpeg: tools/ffmpeg
|
||||||
|
ffprobe: tools/ffprobe
|
||||||
defaults:
|
defaults:
|
||||||
|
stage_root: "{paths.cache}/music"
|
||||||
|
credits_root: "{paths.cache}/credits"
|
||||||
|
credits_overlay: CREDITS.md
|
||||||
max_stem_length: 16
|
max_stem_length: 16
|
||||||
|
naming_scheme: nwn_bmu
|
||||||
|
output_extension: .bmu
|
||||||
|
convert_extensions: [.mp3, .ogg]
|
||||||
datasets:
|
datasets:
|
||||||
westgate:
|
westgate:
|
||||||
source: envi/music/westgate
|
source: envi/music/westgate
|
||||||
|
output: envi/music/westgate
|
||||||
prefix: mus_wg_
|
prefix: mus_wg_
|
||||||
|
package_mode: hak_asset
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolved configuration can be inspected without scanning or building:
|
Resolved configuration can be inspected without scanning or building:
|
||||||
@@ -112,6 +123,12 @@ inventory:
|
|||||||
asset_extensions: [] # empty means built-in NWN asset extensions
|
asset_extensions: [] # empty means built-in NWN asset extensions
|
||||||
source_json_pattern: "{resref}.{extension}.json"
|
source_json_pattern: "{resref}.{extension}.json"
|
||||||
|
|
||||||
|
validation:
|
||||||
|
profile: nwn_module
|
||||||
|
builtin_script_prefixes: [nw_, x0_, x1_, x2_, x3_, ga_, gc_, gen_, gui_, nwg_, ta_]
|
||||||
|
required_fields:
|
||||||
|
ifo: [Mod_Name]
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
source_dir: scripts
|
source_dir: scripts
|
||||||
cache: "{paths.cache}/ncs"
|
cache: "{paths.cache}/ncs"
|
||||||
@@ -169,11 +186,34 @@ music:
|
|||||||
credits_root: "{paths.cache}/credits"
|
credits_root: "{paths.cache}/credits"
|
||||||
credits_overlay: CREDITS.md
|
credits_overlay: CREDITS.md
|
||||||
max_stem_length: 16
|
max_stem_length: 16
|
||||||
|
naming_scheme: nwn_bmu
|
||||||
|
output_extension: .bmu
|
||||||
|
convert_extensions: [.mp3, .ogg]
|
||||||
```
|
```
|
||||||
|
|
||||||
Music datasets define individual music collections with independent sources and
|
Music datasets define individual music collections with independent sources and
|
||||||
naming prefixes. The legacy `music.prefixes` shorthand is automatically normalized
|
naming prefixes. `source` is the authored input root under `paths.assets`;
|
||||||
into a dataset.
|
`output` is the generated HAK asset root matched by HAK include globs. If
|
||||||
|
`output` is omitted, it defaults to `source`. `package_mode: hak_asset` converts
|
||||||
|
configured source extensions into generated HAK resources; `package_mode: none`
|
||||||
|
scans and writes credits without adding generated HAK resources. The legacy
|
||||||
|
`music.prefixes` shorthand is automatically normalized into datasets.
|
||||||
|
|
||||||
|
Music can also be run directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sow-toolkit music list-datasets
|
||||||
|
sow-toolkit music scan --dataset westgate
|
||||||
|
sow-toolkit music build --dataset westgate --dry-run
|
||||||
|
sow-toolkit music build --dataset westgate
|
||||||
|
sow-toolkit music credits --dataset westgate --check
|
||||||
|
sow-toolkit music validate --dataset westgate --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-haks` uses the same music pipeline. Use `--plan-only` or music
|
||||||
|
`--dry-run` to compute mappings without transcoding. Use `build-haks
|
||||||
|
--skip-music` to package authored assets only, or `build-haks --music-dataset
|
||||||
|
westgate` to limit conversion to a configured dataset.
|
||||||
|
|
||||||
Generated and machine-readable artifacts remain JSON, including HAK manifests,
|
Generated and machine-readable artifacts remain JSON, including HAK manifests,
|
||||||
topdata datasets, caches, credits inventories, build reports, and canonical GFF
|
topdata datasets, caches, credits inventories, build reports, and canonical GFF
|
||||||
|
|||||||
+170
-7
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -163,6 +164,11 @@ var commands = []command{
|
|||||||
description: "Inspect, explain, and validate the resolved project configuration.",
|
description: "Inspect, explain, and validate the resolved project configuration.",
|
||||||
run: runConfig,
|
run: runConfig,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "music",
|
||||||
|
description: "List, scan, build, and validate configured music datasets.",
|
||||||
|
run: runMusic,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "compare",
|
name: "compare",
|
||||||
description: "Compare current source content against the built archives.",
|
description: "Compare current source content against the built archives.",
|
||||||
@@ -336,6 +342,8 @@ type buildHAKOptions struct {
|
|||||||
filteredHAKs []string
|
filteredHAKs []string
|
||||||
filteredArchives []string
|
filteredArchives []string
|
||||||
sourceManifest string
|
sourceManifest string
|
||||||
|
musicDatasets []string
|
||||||
|
skipMusic bool
|
||||||
planOnly bool
|
planOnly bool
|
||||||
logLevel logLevel
|
logLevel logLevel
|
||||||
}
|
}
|
||||||
@@ -567,14 +575,17 @@ func runBuildHAKs(ctx context) error {
|
|||||||
spin.configure(ctx.stderr, console.spinnerEnabled)
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
||||||
spin.update("Build HAKs: starting")
|
spin.update("Build HAKs: starting")
|
||||||
var result pipeline.BuildResult
|
var result pipeline.BuildResult
|
||||||
|
pipelineOpts := pipeline.BuildHAKOptions{
|
||||||
|
Progress: console.progress,
|
||||||
|
ArchiveNames: opts.filteredArchives,
|
||||||
|
SourceManifestPath: opts.sourceManifest,
|
||||||
|
SkipMusic: opts.skipMusic,
|
||||||
|
MusicDatasetIDs: opts.musicDatasets,
|
||||||
|
}
|
||||||
if opts.planOnly {
|
if opts.planOnly {
|
||||||
result, err = pipeline.PlanHAKsWithProgress(p, console.progress)
|
result, err = pipeline.PlanHAKsWithOptions(p, pipelineOpts)
|
||||||
} else {
|
} else {
|
||||||
result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{
|
result, err = pipeline.BuildHAKsWithOptions(p, pipelineOpts)
|
||||||
Progress: console.progress,
|
|
||||||
ArchiveNames: opts.filteredArchives,
|
|
||||||
SourceManifestPath: opts.sourceManifest,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -594,7 +605,7 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
|
|||||||
arg := args[index]
|
arg := args[index]
|
||||||
switch arg {
|
switch arg {
|
||||||
case "-h", "--help":
|
case "-h", "--help":
|
||||||
return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--plan-only] [--quiet|--verbose|--debug]")
|
return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--plan-only] [--skip-music] [--music-dataset <id> ...] [--quiet|--verbose|--debug]")
|
||||||
case "--hak":
|
case "--hak":
|
||||||
index++
|
index++
|
||||||
if index >= len(args) {
|
if index >= len(args) {
|
||||||
@@ -609,6 +620,14 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
|
|||||||
opts.filteredArchives = append(opts.filteredArchives, args[index])
|
opts.filteredArchives = append(opts.filteredArchives, args[index])
|
||||||
case "--plan-only":
|
case "--plan-only":
|
||||||
opts.planOnly = true
|
opts.planOnly = true
|
||||||
|
case "--skip-music":
|
||||||
|
opts.skipMusic = true
|
||||||
|
case "--music-dataset":
|
||||||
|
index++
|
||||||
|
if index >= len(args) {
|
||||||
|
return opts, errors.New("--music-dataset requires a value")
|
||||||
|
}
|
||||||
|
opts.musicDatasets = append(opts.musicDatasets, args[index])
|
||||||
case "--quiet":
|
case "--quiet":
|
||||||
opts.logLevel = logLevelQuiet
|
opts.logLevel = logLevelQuiet
|
||||||
case "--verbose":
|
case "--verbose":
|
||||||
@@ -634,6 +653,10 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
|
|||||||
opts.sourceManifest = value
|
opts.sourceManifest = value
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if value, ok := parseInlineFlagValue(arg, "--music-dataset"); ok {
|
||||||
|
opts.musicDatasets = append(opts.musicDatasets, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if arg == "--quiet=true" {
|
if arg == "--quiet=true" {
|
||||||
opts.logLevel = logLevelQuiet
|
opts.logLevel = logLevelQuiet
|
||||||
continue
|
continue
|
||||||
@@ -652,6 +675,146 @@ func parseInlineFlagValue(arg, name string) (string, bool) {
|
|||||||
return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true
|
return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type musicCommandOptions struct {
|
||||||
|
datasets []string
|
||||||
|
json bool
|
||||||
|
dryRun bool
|
||||||
|
check bool
|
||||||
|
force bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMusic(ctx context) error {
|
||||||
|
if len(ctx.args) < 2 {
|
||||||
|
return errors.New("usage: music <list-datasets|scan|build|credits|validate|manifest|normalize> [--dataset <id>] [--json] [--dry-run] [--check] [--force]")
|
||||||
|
}
|
||||||
|
p, err := loadProject(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.Scan(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
subcommand := ctx.args[1]
|
||||||
|
opts, err := parseMusicCommandArgs(ctx.args[2:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch subcommand {
|
||||||
|
case "list-datasets":
|
||||||
|
return emitMusicDatasets(ctx, p, opts)
|
||||||
|
case "scan":
|
||||||
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emitMusicResult(ctx, "scan", result, opts)
|
||||||
|
case "build", "credits":
|
||||||
|
write := true
|
||||||
|
if subcommand == "build" && opts.dryRun {
|
||||||
|
write = false
|
||||||
|
}
|
||||||
|
if subcommand == "credits" && opts.check {
|
||||||
|
write = false
|
||||||
|
}
|
||||||
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: write})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emitMusicResult(ctx, subcommand, result, opts)
|
||||||
|
case "manifest", "normalize":
|
||||||
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emitMusicResult(ctx, subcommand, result, opts)
|
||||||
|
case "validate":
|
||||||
|
if err := p.ValidateLayout(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emitMusicResult(ctx, "validate", result, opts)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown music subcommand %q", subcommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMusicCommandArgs(args []string) (musicCommandOptions, error) {
|
||||||
|
opts := musicCommandOptions{}
|
||||||
|
for index := 0; index < len(args); index++ {
|
||||||
|
arg := args[index]
|
||||||
|
switch arg {
|
||||||
|
case "--dataset":
|
||||||
|
index++
|
||||||
|
if index >= len(args) || strings.TrimSpace(args[index]) == "" {
|
||||||
|
return opts, errors.New("--dataset requires a value")
|
||||||
|
}
|
||||||
|
opts.datasets = append(opts.datasets, args[index])
|
||||||
|
case "--json":
|
||||||
|
opts.json = true
|
||||||
|
case "--dry-run":
|
||||||
|
opts.dryRun = true
|
||||||
|
case "--check":
|
||||||
|
opts.check = true
|
||||||
|
case "--force":
|
||||||
|
opts.force = true
|
||||||
|
default:
|
||||||
|
if value, ok := parseInlineFlagValue(arg, "--dataset"); ok {
|
||||||
|
opts.datasets = append(opts.datasets, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return opts, fmt.Errorf("unknown music argument %q", arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitMusicDatasets(ctx context, p *project.Project, opts musicCommandOptions) error {
|
||||||
|
effective := p.EffectiveConfig()
|
||||||
|
if opts.json {
|
||||||
|
return emitConfigValue(ctx.stdout, effective.Music.Datasets, "json")
|
||||||
|
}
|
||||||
|
if len(effective.Music.Datasets) == 0 {
|
||||||
|
fmt.Fprintln(ctx.stdout, "music datasets: none")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintln(ctx.stdout, "music datasets:")
|
||||||
|
ids := make([]string, 0, len(effective.Music.Datasets))
|
||||||
|
for id := range effective.Music.Datasets {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
for _, id := range ids {
|
||||||
|
ds := effective.Music.Datasets[id]
|
||||||
|
fmt.Fprintf(ctx.stdout, " %s: source=%s output=%s prefix=%s\n", id, ds.Source, ds.Output, ds.Prefix)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitMusicResult(ctx context, action string, result pipeline.BuildResult, opts musicCommandOptions) error {
|
||||||
|
if opts.json {
|
||||||
|
return emitConfigValue(ctx.stdout, result.CreditsSummary, "json")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ctx.stdout, "music %s\n", action)
|
||||||
|
if len(result.CreditsSummary.ScannedDirs) > 0 {
|
||||||
|
fmt.Fprintf(ctx.stdout, "scanned: %s\n", strings.Join(result.CreditsSummary.ScannedDirs, ", "))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ctx.stdout, "tracks: %d\n", result.CreditsSummary.TrackCount)
|
||||||
|
if action != "scan" && len(result.CreditsArtifactPaths) > 0 {
|
||||||
|
fmt.Fprintf(ctx.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
|
||||||
|
}
|
||||||
|
if len(result.CreditsSummary.Mappings) > 0 && ctx.logLevel >= logLevelVerbose {
|
||||||
|
fmt.Fprintln(ctx.stdout, "mappings:")
|
||||||
|
for _, mapping := range result.CreditsSummary.Mappings {
|
||||||
|
fmt.Fprintf(ctx.stdout, " %s -> %s\n", mapping.Source, mapping.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(ctx.stdout, "status: ok")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runExtract(ctx context) error {
|
func runExtract(ctx context) error {
|
||||||
p, err := loadProject(ctx)
|
p, err := loadProject(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -198,6 +198,80 @@ func TestRunBuildHAKsVerboseListsMappings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunMusicScanUsesConfiguredDatasetsWithoutWriting(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkdirAll(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "build"))
|
||||||
|
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
music:
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.mp3"), "source-mp3")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
ctx := context{
|
||||||
|
stdout: &stdout,
|
||||||
|
stderr: &bytes.Buffer{},
|
||||||
|
cwd: root,
|
||||||
|
args: []string{"music", "scan", "--dataset", "westgate_audio"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runMusic(ctx); err != nil {
|
||||||
|
t.Fatalf("runMusic failed: %v", err)
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
if !strings.Contains(output, "music scan") || !strings.Contains(output, "tracks: 1") {
|
||||||
|
t.Fatalf("unexpected music scan output: %q", output)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, ".cache", "credits")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("music scan should not write credits artifacts, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMusicListDatasets(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkdirAll(t, filepath.Join(root, "assets"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "build"))
|
||||||
|
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
music:
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
`)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
ctx := context{
|
||||||
|
stdout: &stdout,
|
||||||
|
stderr: &bytes.Buffer{},
|
||||||
|
cwd: root,
|
||||||
|
args: []string{"music", "list-datasets"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runMusic(ctx); err != nil {
|
||||||
|
t.Fatalf("runMusic failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "westgate_audio: source=audio/westgate output=generated/music prefix=wg_") {
|
||||||
|
t.Fatalf("unexpected dataset list: %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunConfigEffectiveJSONShowsDefaultsAndProvenance(t *testing.T) {
|
func TestRunConfigEffectiveJSONShowsDefaultsAndProvenance(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ type CreditsOverlay struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveFFmpeg() (string, error) {
|
func ResolveFFmpeg() (string, error) {
|
||||||
|
return ResolveFFmpegWithConfig("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveFFmpegWithConfig(configuredPath string) (string, error) {
|
||||||
|
if configured := strings.TrimSpace(configuredPath); configured != "" {
|
||||||
|
return configured, nil
|
||||||
|
}
|
||||||
if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" {
|
if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" {
|
||||||
return configured, nil
|
return configured, nil
|
||||||
}
|
}
|
||||||
@@ -89,6 +96,13 @@ func ResolveFFmpeg() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveFFprobe() (string, error) {
|
func ResolveFFprobe() (string, error) {
|
||||||
|
return ResolveFFprobeWithConfig("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveFFprobeWithConfig(configuredPath string) (string, error) {
|
||||||
|
if configured := strings.TrimSpace(configuredPath); configured != "" {
|
||||||
|
return configured, nil
|
||||||
|
}
|
||||||
if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" {
|
if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" {
|
||||||
return configured, nil
|
return configured, nil
|
||||||
}
|
}
|
||||||
@@ -104,6 +118,15 @@ func IsMusicAssetPath(rel string) bool {
|
|||||||
return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/")
|
return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PathIsUnder(root, rel string) bool {
|
||||||
|
root = TrimSlashes(filepath.ToSlash(root))
|
||||||
|
rel = TrimSlashes(filepath.ToSlash(rel))
|
||||||
|
if root == "" || rel == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel == root || strings.HasPrefix(rel, root+"/")
|
||||||
|
}
|
||||||
|
|
||||||
func PrefixForDir(prefixes map[string]string, dir string) string {
|
func PrefixForDir(prefixes map[string]string, dir string) string {
|
||||||
dir = TrimSlashes(filepath.ToSlash(dir))
|
dir = TrimSlashes(filepath.ToSlash(dir))
|
||||||
bestPrefix := ""
|
bestPrefix := ""
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GenerateStem(fileName, prefix string, used map[string]struct{}) (string, error) {
|
func GenerateStem(fileName, prefix string, used map[string]struct{}) (string, error) {
|
||||||
maxCore := MaxStemLen - len(prefix)
|
return GenerateStemWithMax(fileName, prefix, MaxStemLen, used)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateStemWithMax(fileName, prefix string, maxStemLen int, used map[string]struct{}) (string, error) {
|
||||||
|
if maxStemLen <= 0 {
|
||||||
|
maxStemLen = MaxStemLen
|
||||||
|
}
|
||||||
|
maxCore := maxStemLen - len(prefix)
|
||||||
if maxCore < 3 {
|
if maxCore < 3 {
|
||||||
return "", fmt.Errorf("prefix too long: %q", prefix)
|
return "", fmt.Errorf("prefix too long: %q", prefix)
|
||||||
}
|
}
|
||||||
@@ -19,11 +26,11 @@ func GenerateStem(fileName, prefix string, used map[string]struct{}) (string, er
|
|||||||
sum := sha1.Sum([]byte(fileName))
|
sum := sha1.Sum([]byte(fileName))
|
||||||
core = fmt.Sprintf("%x", sum[:])[:maxCore]
|
core = fmt.Sprintf("%x", sum[:])[:maxCore]
|
||||||
}
|
}
|
||||||
stem := strings.TrimRight((prefix + core)[:Min(MaxStemLen, len(prefix)+len(core))], "_")
|
stem := strings.TrimRight((prefix + core)[:Min(maxStemLen, len(prefix)+len(core))], "_")
|
||||||
if stem == "" {
|
if stem == "" {
|
||||||
return "", fmt.Errorf("could not derive music stem for %s", fileName)
|
return "", fmt.Errorf("could not derive music stem for %s", fileName)
|
||||||
}
|
}
|
||||||
return UniqueName(stem, used), nil
|
return UniqueNameWithMax(stem, maxStemLen, used), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SlugWords(value string) []string {
|
func SlugWords(value string) []string {
|
||||||
@@ -119,13 +126,20 @@ func AbbreviateWord(word string, maxLen int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UniqueName(stem string, used map[string]struct{}) string {
|
func UniqueName(stem string, used map[string]struct{}) string {
|
||||||
|
return UniqueNameWithMax(stem, MaxStemLen, used)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UniqueNameWithMax(stem string, maxStemLen int, used map[string]struct{}) string {
|
||||||
|
if maxStemLen <= 0 {
|
||||||
|
maxStemLen = MaxStemLen
|
||||||
|
}
|
||||||
if _, exists := used[stem]; !exists {
|
if _, exists := used[stem]; !exists {
|
||||||
used[stem] = struct{}{}
|
used[stem] = struct{}{}
|
||||||
return stem
|
return stem
|
||||||
}
|
}
|
||||||
for index := 1; ; index++ {
|
for index := 1; ; index++ {
|
||||||
suffix := fmt.Sprintf("_%d", index)
|
suffix := fmt.Sprintf("_%d", index)
|
||||||
prefixLen := Min(len(stem), Max(0, MaxStemLen-len(suffix)))
|
prefixLen := Min(len(stem), Max(0, maxStemLen-len(suffix)))
|
||||||
candidate := strings.TrimRight(stem[:prefixLen], "_") + suffix
|
candidate := strings.TrimRight(stem[:prefixLen], "_") + suffix
|
||||||
if _, exists := used[candidate]; exists {
|
if _, exists := used[candidate]; exists {
|
||||||
continue
|
continue
|
||||||
@@ -148,16 +162,27 @@ func ReserveName(stem string, used map[string]struct{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateManualOutputFile(name string) error {
|
func ValidateManualOutputFile(name string) error {
|
||||||
|
return ValidateManualOutputFileWithRules(name, ".bmu", MaxStemLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateManualOutputFileWithRules(name, extension string, maxStemLen int) error {
|
||||||
name = strings.ToLower(strings.TrimSpace(name))
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
if !strings.HasSuffix(name, ".bmu") {
|
extension = strings.ToLower(strings.TrimSpace(extension))
|
||||||
return fmt.Errorf("output file must end with .bmu")
|
if extension == "" {
|
||||||
|
extension = ".bmu"
|
||||||
}
|
}
|
||||||
stem := strings.TrimSuffix(name, ".bmu")
|
if maxStemLen <= 0 {
|
||||||
|
maxStemLen = MaxStemLen
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(name, extension) {
|
||||||
|
return fmt.Errorf("output file must end with %s", extension)
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(name, extension)
|
||||||
if stem == "" {
|
if stem == "" {
|
||||||
return fmt.Errorf("output file stem is empty")
|
return fmt.Errorf("output file stem is empty")
|
||||||
}
|
}
|
||||||
if len(stem) > MaxStemLen {
|
if len(stem) > maxStemLen {
|
||||||
return fmt.Errorf("output file stem %q exceeds %d characters", stem, MaxStemLen)
|
return fmt.Errorf("output file stem %q exceeds %d characters", stem, maxStemLen)
|
||||||
}
|
}
|
||||||
for _, r := range stem {
|
for _, r := range stem {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ type BuildHAKOptions struct {
|
|||||||
Progress ProgressFunc
|
Progress ProgressFunc
|
||||||
ArchiveNames []string
|
ArchiveNames []string
|
||||||
SourceManifestPath string
|
SourceManifestPath string
|
||||||
|
SkipMusic bool
|
||||||
|
MusicDatasetIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Build(p *project.Project) (BuildResult, error) {
|
func Build(p *project.Project) (BuildResult, error) {
|
||||||
@@ -195,7 +197,7 @@ func BuildHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) {
|
func BuildHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) {
|
||||||
return planOrBuildHAKs(p, opts.Progress, true, opts.ArchiveNames, opts.SourceManifestPath)
|
return planOrBuildHAKs(p, opts.Progress, true, opts.ArchiveNames, opts.SourceManifestPath, opts.SkipMusic, opts.MusicDatasetIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PlanHAKs(p *project.Project) (BuildResult, error) {
|
func PlanHAKs(p *project.Project) (BuildResult, error) {
|
||||||
@@ -206,15 +208,19 @@ func PlanHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResul
|
|||||||
return planHAKs(p, progress)
|
return planHAKs(p, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PlanHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) {
|
||||||
|
return planOrBuildHAKs(p, opts.Progress, false, opts.ArchiveNames, opts.SourceManifestPath, opts.SkipMusic, opts.MusicDatasetIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func buildHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
|
func buildHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
|
||||||
return planOrBuildHAKs(p, progress, true, nil, "")
|
return planOrBuildHAKs(p, progress, true, nil, "", false, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func planHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
|
func planHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
|
||||||
return planOrBuildHAKs(p, progress, false, nil, "")
|
return planOrBuildHAKs(p, progress, false, nil, "", false, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, archiveNames []string, sourceManifestPath string) (result BuildResult, err error) {
|
func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, archiveNames []string, sourceManifestPath string, skipMusic bool, musicDatasetIDs []string) (result BuildResult, err error) {
|
||||||
preserveExistingHAKs := p.EffectiveConfig().Build.KeepExistingHAKs || envBool("SOW_BUILD_HAKS_KEEP_EXISTING")
|
preserveExistingHAKs := p.EffectiveConfig().Build.KeepExistingHAKs || envBool("SOW_BUILD_HAKS_KEEP_EXISTING")
|
||||||
|
|
||||||
progressf(progress, "Validating project...")
|
progressf(progress, "Validating project...")
|
||||||
@@ -227,9 +233,15 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
musicAssets, err := prepareMusicAssets(p)
|
musicAssets := &preparedMusicAssets{SkipSourceRel: map[string]struct{}{}}
|
||||||
if err != nil {
|
if !skipMusic {
|
||||||
return BuildResult{}, err
|
musicAssets, err = prepareMusicAssetsWithOptions(p, musicPrepareOptions{
|
||||||
|
datasetIDs: musicDatasetIDs,
|
||||||
|
write: writeArchives,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if cleanupErr := cleanupPreparedMusicAssets(musicAssets); cleanupErr != nil && err == nil {
|
if cleanupErr := cleanupPreparedMusicAssets(musicAssets); cleanupErr != nil && err == nil {
|
||||||
|
|||||||
+318
-75
@@ -21,16 +21,193 @@ type preparedMusicAssets struct {
|
|||||||
Summary CreditsRefreshSummary
|
Summary CreditsRefreshSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MusicBuildOptions struct {
|
||||||
|
DatasetIDs []string
|
||||||
|
Write bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type musicPrepareOptions struct {
|
||||||
|
datasetIDs []string
|
||||||
|
write bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type musicSourceGroup struct {
|
||||||
|
DatasetID string
|
||||||
|
Dataset project.EffectiveMusicDataset
|
||||||
|
SourceDir string
|
||||||
|
OutputDir string
|
||||||
|
Sources []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type musicCreditGroup struct {
|
||||||
|
CreditsRoot string
|
||||||
|
SourceDir string
|
||||||
|
Entries []music.CreditsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func musicRootPath(p *project.Project, path string) string {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return p.Root
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(p.Root, filepath.FromSlash(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveMusicDatasets(p *project.Project, onlyIDs []string) (map[string]project.EffectiveMusicDataset, error) {
|
||||||
|
effective := p.EffectiveConfig()
|
||||||
|
wanted := map[string]struct{}{}
|
||||||
|
for _, id := range onlyIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
wanted[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
datasets := map[string]project.EffectiveMusicDataset{}
|
||||||
|
for id, dataset := range effective.Music.Datasets {
|
||||||
|
if len(wanted) > 0 {
|
||||||
|
if _, ok := wanted[id]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(wanted, id)
|
||||||
|
}
|
||||||
|
datasets[id] = dataset
|
||||||
|
}
|
||||||
|
if len(wanted) > 0 {
|
||||||
|
unknown := make([]string, 0, len(wanted))
|
||||||
|
for id := range wanted {
|
||||||
|
unknown = append(unknown, id)
|
||||||
|
}
|
||||||
|
sort.Strings(unknown)
|
||||||
|
return nil, fmt.Errorf("unknown music dataset(s): %s", strings.Join(unknown, ", "))
|
||||||
|
}
|
||||||
|
if len(datasets) == 0 && len(wanted) == 0 {
|
||||||
|
for _, rel := range p.Inventory.AssetFiles {
|
||||||
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
|
if !music.PathIsUnder("envi/music", rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := extensionSet(effective.Music.ConvertExtensions)[ext]; ok {
|
||||||
|
datasets["legacy_envi_music"] = project.EffectiveMusicDataset{
|
||||||
|
Source: "envi/music",
|
||||||
|
Output: "envi/music",
|
||||||
|
StageRoot: effective.Music.StageRoot,
|
||||||
|
CreditsRoot: effective.Music.CreditsRoot,
|
||||||
|
NamingScheme: effective.Music.NamingScheme,
|
||||||
|
OutputExtension: effective.Music.OutputExtension,
|
||||||
|
MaxStemLength: effective.Music.MaxStemLength,
|
||||||
|
PackageMode: "hak_asset",
|
||||||
|
ConvertExtensions: effective.Music.ConvertExtensions,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return datasets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extensionSet(values []string) map[string]struct{} {
|
||||||
|
out := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value != "" {
|
||||||
|
out[value] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func datasetForMusicSource(datasets map[string]project.EffectiveMusicDataset, rel, ext string) (string, project.EffectiveMusicDataset, bool) {
|
||||||
|
bestID := ""
|
||||||
|
var best project.EffectiveMusicDataset
|
||||||
|
bestLen := -1
|
||||||
|
for id, dataset := range datasets {
|
||||||
|
if _, ok := extensionSet(dataset.ConvertExtensions)[ext]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !music.PathIsUnder(dataset.Source, rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(dataset.Source) > bestLen {
|
||||||
|
bestID = id
|
||||||
|
best = dataset
|
||||||
|
bestLen = len(dataset.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestID, best, bestID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedMusicAsset(outputRel, sourceRel string) (assetResource, error) {
|
||||||
|
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(outputRel)), ".")
|
||||||
|
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
|
||||||
|
if !ok {
|
||||||
|
return assetResource{}, fmt.Errorf("unsupported generated music resource extension %q", filepath.Ext(outputRel))
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSuffix(filepath.Base(outputRel), filepath.Ext(outputRel)))
|
||||||
|
return assetResource{
|
||||||
|
Rel: outputRel,
|
||||||
|
Resource: erf.Resource{
|
||||||
|
Name: name,
|
||||||
|
Type: resourceType,
|
||||||
|
},
|
||||||
|
ContentID: "music-plan:" + filepath.ToSlash(sourceRel),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMusic(p *project.Project, opts MusicBuildOptions) (BuildResult, error) {
|
||||||
|
prepared, err := prepareMusicAssetsWithOptions(p, musicPrepareOptions{datasetIDs: opts.DatasetIDs, write: opts.Write})
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
if err := cleanupPreparedMusicAssets(prepared); err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
return BuildResult{
|
||||||
|
CreditsArtifactPaths: prepared.Artifacts,
|
||||||
|
CreditsSummary: prepared.Summary,
|
||||||
|
HAKAssets: len(prepared.Generated),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
||||||
|
return prepareMusicAssetsWithOptions(p, musicPrepareOptions{write: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareMusicAssetsWithOptions(p *project.Project, opts musicPrepareOptions) (*preparedMusicAssets, error) {
|
||||||
usedNames := make(map[string]struct{})
|
usedNames := make(map[string]struct{})
|
||||||
dirSources := make(map[string][]string)
|
groupsByKey := make(map[string]*musicSourceGroup)
|
||||||
|
datasets, err := effectiveMusicDatasets(p, opts.datasetIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for _, rel := range p.Inventory.AssetFiles {
|
for _, rel := range p.Inventory.AssetFiles {
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
ext := strings.ToLower(filepath.Ext(rel))
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
||||||
if _, convertible := music.ConvertExtensions[ext]; convertible && music.IsMusicAssetPath(rel) {
|
if datasetID, dataset, ok := datasetForMusicSource(datasets, rel, ext); ok {
|
||||||
dir := filepath.ToSlash(filepath.Dir(rel))
|
sourceDir := filepath.ToSlash(filepath.Dir(rel))
|
||||||
dirSources[dir] = append(dirSources[dir], rel)
|
relDir, err := filepath.Rel(filepath.FromSlash(dataset.Source), filepath.FromSlash(sourceDir))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve music source directory %s: %w", sourceDir, err)
|
||||||
|
}
|
||||||
|
relDir = filepath.ToSlash(relDir)
|
||||||
|
outputDir := dataset.Output
|
||||||
|
if relDir != "." && relDir != "" {
|
||||||
|
outputDir = filepath.ToSlash(filepath.Join(outputDir, filepath.FromSlash(relDir)))
|
||||||
|
}
|
||||||
|
key := datasetID + "\x00" + sourceDir
|
||||||
|
group := groupsByKey[key]
|
||||||
|
if group == nil {
|
||||||
|
group = &musicSourceGroup{
|
||||||
|
DatasetID: datasetID,
|
||||||
|
Dataset: dataset,
|
||||||
|
SourceDir: sourceDir,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
}
|
||||||
|
groupsByKey[key] = group
|
||||||
|
}
|
||||||
|
group.Sources = append(group.Sources, rel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if name != "" {
|
if name != "" {
|
||||||
@@ -41,7 +218,10 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
|||||||
result := &preparedMusicAssets{
|
result := &preparedMusicAssets{
|
||||||
SkipSourceRel: make(map[string]struct{}),
|
SkipSourceRel: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
if len(dirSources) == 0 {
|
if len(groupsByKey) == 0 {
|
||||||
|
if !opts.write {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
writeResult, err := writeCreditsArtifacts(p, nil)
|
writeResult, err := writeCreditsArtifacts(p, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -51,57 +231,86 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs := make([]string, 0, len(dirSources))
|
groups := make([]*musicSourceGroup, 0, len(groupsByKey))
|
||||||
for dir := range dirSources {
|
for _, group := range groupsByKey {
|
||||||
dirs = append(dirs, dir)
|
sort.Strings(group.Sources)
|
||||||
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
sort.Strings(dirs)
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
|
if groups[i].SourceDir != groups[j].SourceDir {
|
||||||
|
return groups[i].SourceDir < groups[j].SourceDir
|
||||||
|
}
|
||||||
|
return groups[i].DatasetID < groups[j].DatasetID
|
||||||
|
})
|
||||||
|
|
||||||
generatedByDir := make(map[string][]music.CreditsEntry)
|
creditGroups := make([]musicCreditGroup, 0, len(groups))
|
||||||
stageRoot := p.MusicStageDir()
|
for _, group := range groups {
|
||||||
result.TempRoots = append(result.TempRoots, stageRoot)
|
result.Summary.ScannedDirs = append(result.Summary.ScannedDirs, group.SourceDir)
|
||||||
|
result.Summary.TrackCount += len(group.Sources)
|
||||||
ffmpegPath, err := music.ResolveFFmpeg()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
ffprobePath, err := music.ResolveFFprobe()
|
ffmpegPath := ""
|
||||||
if err != nil {
|
ffprobePath := ""
|
||||||
return nil, err
|
if opts.write {
|
||||||
|
var err error
|
||||||
|
ffmpegPath, err = music.ResolveFFmpegWithConfig(p.MusicFFmpegPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ffprobePath, err = music.ResolveFFprobeWithConfig(p.MusicFFprobePath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, group := range groups {
|
||||||
sources := append([]string(nil), dirSources[dir]...)
|
stageRoot := musicRootPath(p, group.Dataset.StageRoot)
|
||||||
sort.Strings(sources)
|
result.TempRoots = append(result.TempRoots, stageRoot)
|
||||||
prefix := music.PrefixForDir(p.EffectiveConfig().Music.Prefixes, dir)
|
prefix := music.SanitizePrefix(group.Dataset.Prefix)
|
||||||
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay)
|
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(group.SourceDir), p.EffectiveConfig().Music.CreditsOverlay)
|
||||||
overlay, err := music.ParseCreditsOverlay(overlayPath)
|
overlay, err := music.ParseCreditsOverlay(overlayPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
|
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := make([]music.CreditsEntry, 0, len(sources))
|
entries := make([]music.CreditsEntry, 0, len(group.Sources))
|
||||||
for _, rel := range sources {
|
for _, rel := range group.Sources {
|
||||||
base := filepath.Base(rel)
|
base := filepath.Base(rel)
|
||||||
manual := overlay.Match(base, "")
|
manual := overlay.Match(base, "")
|
||||||
outputFile := ""
|
outputFile := ""
|
||||||
switch {
|
switch {
|
||||||
case manual != nil && strings.TrimSpace(manual.OutputFile) != "":
|
case manual != nil && strings.TrimSpace(manual.OutputFile) != "":
|
||||||
outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile))
|
outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile))
|
||||||
if err := music.ValidateManualOutputFile(outputFile); err != nil {
|
if err := music.ValidateManualOutputFileWithRules(outputFile, group.Dataset.OutputExtension, group.Dataset.MaxStemLength); err != nil {
|
||||||
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
if err := music.ReserveName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil {
|
if err := music.ReserveName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil {
|
||||||
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
stem, err := music.GenerateStem(base, prefix, usedNames)
|
stem, err := music.GenerateStemWithMax(base, prefix, group.Dataset.MaxStemLength, usedNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
|
return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
outputFile = stem + ".bmu"
|
outputFile = stem + group.Dataset.OutputExtension
|
||||||
}
|
}
|
||||||
outputRel := filepath.ToSlash(filepath.Join(dir, outputFile))
|
outputRel := filepath.ToSlash(filepath.Join(group.OutputDir, outputFile))
|
||||||
|
result.Summary.Mappings = append(result.Summary.Mappings, MusicFileMapping{
|
||||||
|
Source: rel,
|
||||||
|
Output: outputRel,
|
||||||
|
})
|
||||||
|
result.SkipSourceRel[rel] = struct{}{}
|
||||||
|
|
||||||
|
if !opts.write {
|
||||||
|
if group.Dataset.PackageMode == "hak_asset" {
|
||||||
|
asset, err := plannedMusicAsset(outputRel, rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Generated = append(result.Generated, asset)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
metadata, err := music.ProbeMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
metadata, err := music.ProbeMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
|
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
|
||||||
@@ -125,36 +334,50 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
|||||||
if err := music.ConvertSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil {
|
if err := music.ConvertSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil {
|
||||||
return nil, fmt.Errorf("convert music %s: %w", rel, err)
|
return nil, fmt.Errorf("convert music %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false)
|
if group.Dataset.PackageMode == "hak_asset" {
|
||||||
if err != nil {
|
resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false)
|
||||||
return nil, fmt.Errorf("load converted music %s: %w", outputRel, err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load converted music %s: %w", outputRel, err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(stagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err)
|
||||||
|
}
|
||||||
|
result.Generated = append(result.Generated, assetResource{
|
||||||
|
Rel: outputRel,
|
||||||
|
Resource: resourceInfo.Resource,
|
||||||
|
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
|
||||||
|
CreatedAt: info.ModTime(),
|
||||||
|
ContentID: resourceInfo.ContentID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
info, err := os.Stat(stagePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err)
|
|
||||||
}
|
|
||||||
result.Generated = append(result.Generated, assetResource{
|
|
||||||
Rel: outputRel,
|
|
||||||
Resource: resourceInfo.Resource,
|
|
||||||
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
|
|
||||||
CreatedAt: info.ModTime(),
|
|
||||||
ContentID: resourceInfo.ContentID,
|
|
||||||
})
|
|
||||||
result.SkipSourceRel[rel] = struct{}{}
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
if err := music.ValidateOverlayEntries(dir, overlay, entries); err != nil {
|
if opts.write {
|
||||||
return nil, err
|
if err := music.ValidateOverlayEntries(group.SourceDir, overlay, entries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creditGroups = append(creditGroups, musicCreditGroup{
|
||||||
|
CreditsRoot: group.Dataset.CreditsRoot,
|
||||||
|
SourceDir: group.SourceDir,
|
||||||
|
Entries: entries,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
generatedByDir[dir] = entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeResult, err := writeCreditsArtifacts(p, generatedByDir)
|
if !opts.write {
|
||||||
|
sort.Slice(result.Generated, func(i, j int) bool {
|
||||||
|
return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
writeResult, err := writeCreditsArtifacts(p, creditGroups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, dirs...)
|
writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, result.Summary.ScannedDirs...)
|
||||||
writeResult.Summary.TrackCount = len(result.Generated)
|
writeResult.Summary.TrackCount = result.Summary.TrackCount
|
||||||
result.Artifacts = writeResult.Artifacts
|
result.Artifacts = writeResult.Artifacts
|
||||||
result.Summary = writeResult.Summary
|
result.Summary = writeResult.Summary
|
||||||
sort.Slice(result.Generated, func(i, j int) bool {
|
sort.Slice(result.Generated, func(i, j int) bool {
|
||||||
@@ -168,19 +391,24 @@ type creditsArtifactWriteResult struct {
|
|||||||
Summary CreditsRefreshSummary
|
Summary CreditsRefreshSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]music.CreditsEntry) (creditsArtifactWriteResult, error) {
|
func writeCreditsArtifacts(p *project.Project, generated []musicCreditGroup) (creditsArtifactWriteResult, error) {
|
||||||
creditsRoot := p.CreditsDir()
|
defaultCreditsRoot := p.CreditsDir()
|
||||||
sources := make([]music.CreditsSource, 0)
|
sources := make([]music.CreditsSource, 0)
|
||||||
desiredFiles := make(map[string][]byte)
|
desiredByRoot := map[string]map[string][]byte{}
|
||||||
summary := CreditsRefreshSummary{}
|
summary := CreditsRefreshSummary{}
|
||||||
if generatedByDir != nil {
|
if generated != nil {
|
||||||
dirs := make([]string, 0, len(generatedByDir))
|
sort.Slice(generated, func(i, j int) bool {
|
||||||
for dir := range generatedByDir {
|
if generated[i].SourceDir != generated[j].SourceDir {
|
||||||
dirs = append(dirs, dir)
|
return generated[i].SourceDir < generated[j].SourceDir
|
||||||
}
|
}
|
||||||
sort.Strings(dirs)
|
return generated[i].CreditsRoot < generated[j].CreditsRoot
|
||||||
for _, dir := range dirs {
|
})
|
||||||
entries := append([]music.CreditsEntry(nil), generatedByDir[dir]...)
|
for _, group := range generated {
|
||||||
|
creditsRoot := musicRootPath(p, group.CreditsRoot)
|
||||||
|
if desiredByRoot[creditsRoot] == nil {
|
||||||
|
desiredByRoot[creditsRoot] = map[string][]byte{}
|
||||||
|
}
|
||||||
|
entries := append([]music.CreditsEntry(nil), group.Entries...)
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) {
|
if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) {
|
||||||
return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist)
|
return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist)
|
||||||
@@ -190,8 +418,8 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]music
|
|||||||
}
|
}
|
||||||
return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
|
return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
|
||||||
})
|
})
|
||||||
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay)
|
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(group.SourceDir), p.EffectiveConfig().Music.CreditsOverlay)
|
||||||
desiredFiles[outputPath] = []byte(music.RenderCreditsMarkdown(entries))
|
desiredByRoot[creditsRoot][outputPath] = []byte(music.RenderCreditsMarkdown(entries))
|
||||||
summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
|
summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
summary.Mappings = append(summary.Mappings, MusicFileMapping{
|
summary.Mappings = append(summary.Mappings, MusicFileMapping{
|
||||||
@@ -237,27 +465,42 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]music
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
|
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
|
||||||
inventoryPath := filepath.Join(creditsRoot, "credits.json")
|
|
||||||
payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ")
|
payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err)
|
return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err)
|
||||||
}
|
}
|
||||||
payload = append(payload, '\n')
|
payload = append(payload, '\n')
|
||||||
desiredFiles[inventoryPath] = payload
|
if len(desiredByRoot) == 0 {
|
||||||
if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
|
desiredByRoot[defaultCreditsRoot] = map[string][]byte{}
|
||||||
return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err)
|
|
||||||
}
|
}
|
||||||
changedFiles, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles)
|
artifacts := make([]string, 0)
|
||||||
if err != nil {
|
changedFiles := 0
|
||||||
return creditsArtifactWriteResult{}, err
|
roots := make([]string, 0, len(desiredByRoot))
|
||||||
|
for root := range desiredByRoot {
|
||||||
|
roots = append(roots, root)
|
||||||
}
|
}
|
||||||
artifacts := make([]string, 0, len(desiredFiles))
|
sort.Strings(roots)
|
||||||
for path := range desiredFiles {
|
for _, creditsRoot := range roots {
|
||||||
artifacts = append(artifacts, path)
|
desiredFiles := desiredByRoot[creditsRoot]
|
||||||
|
inventoryPath := filepath.Join(creditsRoot, "credits.json")
|
||||||
|
desiredFiles[inventoryPath] = payload
|
||||||
|
if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
|
||||||
|
return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err)
|
||||||
|
}
|
||||||
|
changed, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles)
|
||||||
|
if err != nil {
|
||||||
|
return creditsArtifactWriteResult{}, err
|
||||||
|
}
|
||||||
|
changedFiles += changed
|
||||||
|
for path := range desiredFiles {
|
||||||
|
artifacts = append(artifacts, path)
|
||||||
|
}
|
||||||
|
if summary.InventoryPath == "" {
|
||||||
|
summary.InventoryPath = inventoryPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(artifacts)
|
sort.Strings(artifacts)
|
||||||
summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...)
|
summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...)
|
||||||
summary.InventoryPath = inventoryPath
|
|
||||||
summary.ChangedFiles = changedFiles
|
summary.ChangedFiles = changedFiles
|
||||||
return creditsArtifactWriteResult{
|
return creditsArtifactWriteResult{
|
||||||
Artifacts: artifacts,
|
Artifacts: artifacts,
|
||||||
|
|||||||
@@ -2769,4 +2769,153 @@ func TestBuildHAKsConvertsMusicSourcesAndWritesCreditsArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildHAKsUsesExplicitMusicDatasetOutsideLegacyRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
ffprobePath := filepath.Join(root, "tools", "ffprobe")
|
||||||
|
ffmpegPath := filepath.Join(root, "tools", "ffmpeg")
|
||||||
|
mustMkdir(t, filepath.Dir(ffprobePath))
|
||||||
|
mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Dataset Artist\",\"title\":\"Dataset Title\"}}}'\n")
|
||||||
|
mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n")
|
||||||
|
if err := os.Chmod(ffprobePath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffprobe: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Assets
|
||||||
|
resref: testassets
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
music:
|
||||||
|
tools:
|
||||||
|
ffmpeg: tools/ffmpeg
|
||||||
|
ffprobe: tools/ffprobe
|
||||||
|
defaults:
|
||||||
|
credits_root: custom-credits
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
haks:
|
||||||
|
- name: music
|
||||||
|
priority: 1
|
||||||
|
max_bytes: 1048576
|
||||||
|
split: false
|
||||||
|
include:
|
||||||
|
- generated/music/**
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.mp3"), "source-mp3")
|
||||||
|
|
||||||
|
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 := BuildHAKs(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build haks: %v", err)
|
||||||
|
}
|
||||||
|
if result.CreditsSummary.TrackCount != 1 {
|
||||||
|
t.Fatalf("expected one music track, got %#v", result.CreditsSummary)
|
||||||
|
}
|
||||||
|
manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest: %v", err)
|
||||||
|
}
|
||||||
|
manifestText := string(manifestRaw)
|
||||||
|
if !strings.Contains(manifestText, "generated/music/wg_theme.bmu") {
|
||||||
|
t.Fatalf("expected dataset output resource in manifest, got:\n%s", manifestText)
|
||||||
|
}
|
||||||
|
if strings.Contains(manifestText, "audio/westgate/Theme Song.mp3") {
|
||||||
|
t.Fatalf("did not expect source mp3 in manifest, got:\n%s", manifestText)
|
||||||
|
}
|
||||||
|
creditsRaw, err := os.ReadFile(filepath.Join(root, "custom-credits", "audio", "westgate", "CREDITS.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated dataset credits: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(creditsRaw), "Dataset Artist") || !strings.Contains(string(creditsRaw), "wg_theme.bmu") {
|
||||||
|
t.Fatalf("unexpected generated credits:\n%s", string(creditsRaw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanHAKsDoesNotTranscodeMusicSources(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
ffmpegPath := filepath.Join(root, "tools", "ffmpeg")
|
||||||
|
mustMkdir(t, filepath.Dir(ffmpegPath))
|
||||||
|
mustWriteFile(t, ffmpegPath, "#!/bin/sh\necho should-not-run >&2\nexit 42\n")
|
||||||
|
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Assets
|
||||||
|
resref: testassets
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
music:
|
||||||
|
tools:
|
||||||
|
ffmpeg: tools/ffmpeg
|
||||||
|
ffprobe: tools/ffprobe
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
haks:
|
||||||
|
- name: music
|
||||||
|
priority: 1
|
||||||
|
max_bytes: 1048576
|
||||||
|
split: false
|
||||||
|
include:
|
||||||
|
- generated/music/**
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.mp3"), "source-mp3")
|
||||||
|
|
||||||
|
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 := PlanHAKs(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("plan haks: %v", err)
|
||||||
|
}
|
||||||
|
if result.CreditsSummary.TrackCount != 1 {
|
||||||
|
t.Fatalf("expected one planned music track, got %#v", result.CreditsSummary)
|
||||||
|
}
|
||||||
|
manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(manifestRaw), "generated/music/wg_theme.bmu") {
|
||||||
|
t.Fatalf("expected planned generated music in manifest, got:\n%s", string(manifestRaw))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, ".cache", "music")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("plan-only should not stage music, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ const (
|
|||||||
DefaultHAKManifest = "haks.json"
|
DefaultHAKManifest = "haks.json"
|
||||||
DefaultHAKArchiveTemplate = "{hak.name}.hak"
|
DefaultHAKArchiveTemplate = "{hak.name}.hak"
|
||||||
DefaultSourceJSONPattern = "{resref}.{extension}.json"
|
DefaultSourceJSONPattern = "{resref}.{extension}.json"
|
||||||
|
DefaultValidationProfile = "nwn_module"
|
||||||
DefaultScriptsSourceDir = "scripts"
|
DefaultScriptsSourceDir = "scripts"
|
||||||
DefaultScriptsCache = "{paths.cache}/ncs"
|
DefaultScriptsCache = "{paths.cache}/ncs"
|
||||||
DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER"
|
DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER"
|
||||||
DefaultMusicStageRoot = "{paths.cache}/music"
|
DefaultMusicStageRoot = "{paths.cache}/music"
|
||||||
DefaultCreditsRoot = "{paths.cache}/credits"
|
DefaultCreditsRoot = "{paths.cache}/credits"
|
||||||
DefaultCreditsOverlayFile = "CREDITS.md"
|
DefaultCreditsOverlayFile = "CREDITS.md"
|
||||||
|
DefaultMusicNamingScheme = "nwn_bmu"
|
||||||
|
DefaultMusicOutputExtension = ".bmu"
|
||||||
DefaultTopDataBuild = "{paths.build}/topdata"
|
DefaultTopDataBuild = "{paths.build}/topdata"
|
||||||
DefaultTopDataCompiled2DADir = "2da"
|
DefaultTopDataCompiled2DADir = "2da"
|
||||||
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
|
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
|
||||||
@@ -60,6 +63,7 @@ type EffectiveConfig struct {
|
|||||||
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
||||||
Build BuildConfig `json:"build" yaml:"build"`
|
Build BuildConfig `json:"build" yaml:"build"`
|
||||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||||
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
||||||
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
||||||
Music EffectiveMusicConfig `json:"music" yaml:"music"`
|
Music EffectiveMusicConfig `json:"music" yaml:"music"`
|
||||||
TopData EffectiveTopDataConfig `json:"topdata" yaml:"topdata"`
|
TopData EffectiveTopDataConfig `json:"topdata" yaml:"topdata"`
|
||||||
@@ -103,19 +107,29 @@ type EffectiveScriptCompilerEnvConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EffectiveMusicConfig struct {
|
type EffectiveMusicConfig struct {
|
||||||
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
||||||
StageRoot string `json:"stage_root" yaml:"stage_root"`
|
Tools MusicToolsConfig `json:"tools" yaml:"tools"`
|
||||||
CreditsRoot string `json:"credits_root" yaml:"credits_root"`
|
StageRoot string `json:"stage_root" yaml:"stage_root"`
|
||||||
CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"`
|
CreditsRoot string `json:"credits_root" yaml:"credits_root"`
|
||||||
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"`
|
CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"`
|
||||||
Datasets map[string]EffectiveMusicDataset `json:"datasets,omitempty" yaml:"datasets,omitempty"`
|
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"`
|
||||||
|
NamingScheme string `json:"naming_scheme" yaml:"naming_scheme"`
|
||||||
|
OutputExtension string `json:"output_extension" yaml:"output_extension"`
|
||||||
|
ConvertExtensions []string `json:"convert_extensions" yaml:"convert_extensions"`
|
||||||
|
Datasets map[string]EffectiveMusicDataset `json:"datasets,omitempty" yaml:"datasets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EffectiveMusicDataset struct {
|
type EffectiveMusicDataset struct {
|
||||||
Source string `json:"source" yaml:"source"`
|
Source string `json:"source" yaml:"source"`
|
||||||
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
Output string `json:"output" yaml:"output"`
|
||||||
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
||||||
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
||||||
|
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
||||||
|
NamingScheme string `json:"naming_scheme" yaml:"naming_scheme"`
|
||||||
|
OutputExtension string `json:"output_extension" yaml:"output_extension"`
|
||||||
|
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"`
|
||||||
|
PackageMode string `json:"package_mode" yaml:"package_mode"`
|
||||||
|
ConvertExtensions []string `json:"convert_extensions" yaml:"convert_extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EffectiveTopDataConfig struct {
|
type EffectiveTopDataConfig struct {
|
||||||
@@ -177,6 +191,11 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
AssetExtensions: defaultStringSlice(p.Config.Inventory.AssetExtensions, AssetExtensions),
|
AssetExtensions: defaultStringSlice(p.Config.Inventory.AssetExtensions, AssetExtensions),
|
||||||
SourceJSONPattern: defaultString(p.Config.Inventory.SourceJSONPattern, DefaultSourceJSONPattern),
|
SourceJSONPattern: defaultString(p.Config.Inventory.SourceJSONPattern, DefaultSourceJSONPattern),
|
||||||
}
|
}
|
||||||
|
validation := ValidationConfig{
|
||||||
|
Profile: defaultString(p.Config.Validation.Profile, DefaultValidationProfile),
|
||||||
|
BuiltinScriptPrefixes: normalizeLowerStringSlice(defaultStringSlice(p.Config.Validation.BuiltinScriptPrefixes, DefaultBuiltinScriptPrefixes())),
|
||||||
|
RequiredFields: defaultRequiredFields(p.Config.Validation.RequiredFields),
|
||||||
|
}
|
||||||
scripts := EffectiveScriptsConfig{
|
scripts := EffectiveScriptsConfig{
|
||||||
SourceDir: defaultString(p.Config.Scripts.SourceDir, DefaultScriptsSourceDir),
|
SourceDir: defaultString(p.Config.Scripts.SourceDir, DefaultScriptsSourceDir),
|
||||||
Cache: expandPathTemplate(defaultString(p.Config.Scripts.Cache, DefaultScriptsCache), paths),
|
Cache: expandPathTemplate(defaultString(p.Config.Scripts.Cache, DefaultScriptsCache), paths),
|
||||||
@@ -221,6 +240,9 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
musicCreditsRoot := expandPathTemplate(DefaultCreditsRoot, paths)
|
musicCreditsRoot := expandPathTemplate(DefaultCreditsRoot, paths)
|
||||||
musicCreditsOverlay := DefaultCreditsOverlayFile
|
musicCreditsOverlay := DefaultCreditsOverlayFile
|
||||||
musicMaxStemLength := 16
|
musicMaxStemLength := 16
|
||||||
|
musicNamingScheme := DefaultMusicNamingScheme
|
||||||
|
musicOutputExtension := DefaultMusicOutputExtension
|
||||||
|
musicConvertExtensions := []string{".mp3", ".ogg"}
|
||||||
if d := p.Config.Music.Defaults; d != nil {
|
if d := p.Config.Music.Defaults; d != nil {
|
||||||
if d.StageRoot != "" {
|
if d.StageRoot != "" {
|
||||||
musicStageRoot = expandPathTemplate(d.StageRoot, paths)
|
musicStageRoot = expandPathTemplate(d.StageRoot, paths)
|
||||||
@@ -234,6 +256,15 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
if d.MaxStemLength != nil {
|
if d.MaxStemLength != nil {
|
||||||
musicMaxStemLength = *d.MaxStemLength
|
musicMaxStemLength = *d.MaxStemLength
|
||||||
}
|
}
|
||||||
|
if d.NamingScheme != "" {
|
||||||
|
musicNamingScheme = d.NamingScheme
|
||||||
|
}
|
||||||
|
if d.OutputExtension != "" {
|
||||||
|
musicOutputExtension = normalizeExtension(d.OutputExtension)
|
||||||
|
}
|
||||||
|
if len(d.ConvertExtensions) > 0 {
|
||||||
|
musicConvertExtensions = normalizeExtensionSlice(d.ConvertExtensions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
musicPrefixes := cloneStringMap(p.Config.Music.Prefixes)
|
musicPrefixes := cloneStringMap(p.Config.Music.Prefixes)
|
||||||
if len(musicPrefixes) == 0 {
|
if len(musicPrefixes) == 0 {
|
||||||
@@ -254,11 +285,30 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
if dsCreditsRoot == "" {
|
if dsCreditsRoot == "" {
|
||||||
dsCreditsRoot = musicCreditsRoot
|
dsCreditsRoot = musicCreditsRoot
|
||||||
}
|
}
|
||||||
|
dsOutput := strings.TrimSpace(ds.Output)
|
||||||
|
if dsOutput == "" {
|
||||||
|
dsOutput = ds.Source
|
||||||
|
}
|
||||||
|
dsNamingScheme := defaultString(ds.NamingScheme, musicNamingScheme)
|
||||||
|
dsOutputExtension := musicOutputExtension
|
||||||
|
if ds.OutputExtension != "" {
|
||||||
|
dsOutputExtension = normalizeExtension(ds.OutputExtension)
|
||||||
|
}
|
||||||
|
dsConvertExtensions := musicConvertExtensions
|
||||||
|
if len(ds.ConvertExtensions) > 0 {
|
||||||
|
dsConvertExtensions = normalizeExtensionSlice(ds.ConvertExtensions)
|
||||||
|
}
|
||||||
effectiveDatasets[id] = EffectiveMusicDataset{
|
effectiveDatasets[id] = EffectiveMusicDataset{
|
||||||
Source: ds.Source,
|
Source: ds.Source,
|
||||||
Prefix: ds.Prefix,
|
Output: dsOutput,
|
||||||
StageRoot: dsStageRoot,
|
Prefix: ds.Prefix,
|
||||||
CreditsRoot: dsCreditsRoot,
|
StageRoot: dsStageRoot,
|
||||||
|
CreditsRoot: dsCreditsRoot,
|
||||||
|
NamingScheme: dsNamingScheme,
|
||||||
|
OutputExtension: dsOutputExtension,
|
||||||
|
MaxStemLength: musicMaxStemLength,
|
||||||
|
PackageMode: defaultString(ds.PackageMode, "hak_asset"),
|
||||||
|
ConvertExtensions: dsConvertExtensions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,14 +319,19 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
Outputs: outputs,
|
Outputs: outputs,
|
||||||
Build: p.Config.Build,
|
Build: p.Config.Build,
|
||||||
Inventory: inventory,
|
Inventory: inventory,
|
||||||
|
Validation: validation,
|
||||||
Scripts: scripts,
|
Scripts: scripts,
|
||||||
Music: EffectiveMusicConfig{
|
Music: EffectiveMusicConfig{
|
||||||
Prefixes: musicPrefixes,
|
Prefixes: musicPrefixes,
|
||||||
StageRoot: musicStageRoot,
|
Tools: p.Config.Music.Tools,
|
||||||
CreditsRoot: musicCreditsRoot,
|
StageRoot: musicStageRoot,
|
||||||
CreditsOverlay: musicCreditsOverlay,
|
CreditsRoot: musicCreditsRoot,
|
||||||
MaxStemLength: musicMaxStemLength,
|
CreditsOverlay: musicCreditsOverlay,
|
||||||
Datasets: effectiveDatasets,
|
MaxStemLength: musicMaxStemLength,
|
||||||
|
NamingScheme: musicNamingScheme,
|
||||||
|
OutputExtension: musicOutputExtension,
|
||||||
|
ConvertExtensions: musicConvertExtensions,
|
||||||
|
Datasets: effectiveDatasets,
|
||||||
},
|
},
|
||||||
TopData: top,
|
TopData: top,
|
||||||
Extract: extract,
|
Extract: extract,
|
||||||
@@ -353,6 +408,9 @@ func markMissingDefaults(provenance ConfigProvenance) {
|
|||||||
"inventory.source_extensions": "NWN source resource extensions",
|
"inventory.source_extensions": "NWN source resource extensions",
|
||||||
"inventory.asset_extensions": "NWN asset resource extensions",
|
"inventory.asset_extensions": "NWN asset resource extensions",
|
||||||
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
||||||
|
"validation.profile": DefaultValidationProfile,
|
||||||
|
"validation.builtin_script_prefixes": "NWN built-in script prefixes",
|
||||||
|
"validation.required_fields": "NWN required GFF fields",
|
||||||
"scripts.source_dir": DefaultScriptsSourceDir,
|
"scripts.source_dir": DefaultScriptsSourceDir,
|
||||||
"scripts.cache": DefaultScriptsCache,
|
"scripts.cache": DefaultScriptsCache,
|
||||||
"scripts.compiler.env.path": DefaultScriptCompilerEnvPath,
|
"scripts.compiler.env.path": DefaultScriptCompilerEnvPath,
|
||||||
@@ -361,6 +419,9 @@ func markMissingDefaults(provenance ConfigProvenance) {
|
|||||||
"music.credits_root": DefaultCreditsRoot,
|
"music.credits_root": DefaultCreditsRoot,
|
||||||
"music.credits_overlay": DefaultCreditsOverlayFile,
|
"music.credits_overlay": DefaultCreditsOverlayFile,
|
||||||
"music.max_stem_length": "16",
|
"music.max_stem_length": "16",
|
||||||
|
"music.naming_scheme": DefaultMusicNamingScheme,
|
||||||
|
"music.output_extension": DefaultMusicOutputExtension,
|
||||||
|
"music.convert_extensions": ".mp3, .ogg",
|
||||||
"topdata.build": DefaultTopDataBuild,
|
"topdata.build": DefaultTopDataBuild,
|
||||||
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
||||||
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
||||||
@@ -413,6 +474,21 @@ func defaultStringSlice(value, fallback []string) []string {
|
|||||||
return slices.Clone(value)
|
return slices.Clone(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultRequiredFields(configured map[string][]string) map[string][]string {
|
||||||
|
if len(configured) > 0 {
|
||||||
|
return cloneStringSliceMap(normalizeRequiredFields(configured))
|
||||||
|
}
|
||||||
|
return DefaultRequiredFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSliceMap(input map[string][]string) map[string][]string {
|
||||||
|
out := make(map[string][]string, len(input))
|
||||||
|
for key, values := range input {
|
||||||
|
out[key] = slices.Clone(values)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func cloneStringMap(input map[string]string) map[string]string {
|
func cloneStringMap(input map[string]string) map[string]string {
|
||||||
if len(input) == 0 {
|
if len(input) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+241
-20
@@ -33,6 +33,36 @@ var AssetExtensions = []string{
|
|||||||
".2da", ".bik", ".bmp", ".bmu", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mp3", ".mtr", ".ogg", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
|
".2da", ".bik", ".bmp", ".bmu", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mp3", ".mtr", ".ogg", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var BuiltinScriptPrefixes = []string{
|
||||||
|
"nw_",
|
||||||
|
"x0_",
|
||||||
|
"x1_",
|
||||||
|
"x2_",
|
||||||
|
"x3_",
|
||||||
|
"ga_",
|
||||||
|
"gc_",
|
||||||
|
"gen_",
|
||||||
|
"gui_",
|
||||||
|
"nwg_",
|
||||||
|
"ta_",
|
||||||
|
}
|
||||||
|
|
||||||
|
var RequiredFields = map[string][]string{
|
||||||
|
"ifo": {"Mod_Name"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultBuiltinScriptPrefixes() []string {
|
||||||
|
return slices.Clone(BuiltinScriptPrefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRequiredFields() map[string][]string {
|
||||||
|
out := make(map[string][]string, len(RequiredFields))
|
||||||
|
for extension, fields := range RequiredFields {
|
||||||
|
out[extension] = slices.Clone(fields)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
Root string
|
Root string
|
||||||
Config Config
|
Config Config
|
||||||
@@ -49,17 +79,18 @@ type ConfigSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Module ModuleConfig `json:"module" yaml:"module"`
|
Module ModuleConfig `json:"module" yaml:"module"`
|
||||||
Paths PathConfig `json:"paths" yaml:"paths"`
|
Paths PathConfig `json:"paths" yaml:"paths"`
|
||||||
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
||||||
Build BuildConfig `json:"build" yaml:"build"`
|
Build BuildConfig `json:"build" yaml:"build"`
|
||||||
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
||||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||||
Scripts ScriptsConfig `json:"scripts" yaml:"scripts"`
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
||||||
Music MusicConfig `json:"music" yaml:"music"`
|
Scripts ScriptsConfig `json:"scripts" yaml:"scripts"`
|
||||||
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
|
Music MusicConfig `json:"music" yaml:"music"`
|
||||||
Extract ExtractConfig `json:"extract" yaml:"extract"`
|
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
|
||||||
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
|
Extract ExtractConfig `json:"extract" yaml:"extract"`
|
||||||
|
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleConfig struct {
|
type ModuleConfig struct {
|
||||||
@@ -93,6 +124,12 @@ type InventoryConfig struct {
|
|||||||
SourceJSONPattern string `json:"source_json_pattern" yaml:"source_json_pattern"`
|
SourceJSONPattern string `json:"source_json_pattern" yaml:"source_json_pattern"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ValidationConfig struct {
|
||||||
|
Profile string `json:"profile" yaml:"profile"`
|
||||||
|
BuiltinScriptPrefixes []string `json:"builtin_script_prefixes" yaml:"builtin_script_prefixes"`
|
||||||
|
RequiredFields map[string][]string `json:"required_fields" yaml:"required_fields"`
|
||||||
|
}
|
||||||
|
|
||||||
type ScriptsConfig struct {
|
type ScriptsConfig struct {
|
||||||
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
||||||
Cache string `json:"cache" yaml:"cache"`
|
Cache string `json:"cache" yaml:"cache"`
|
||||||
@@ -121,23 +158,37 @@ type HAKConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MusicConfig struct {
|
type MusicConfig struct {
|
||||||
Prefixes map[string]string `json:"prefixes,omitempty" yaml:"prefixes,omitempty"`
|
Prefixes map[string]string `json:"prefixes,omitempty" yaml:"prefixes,omitempty"`
|
||||||
|
Tools MusicToolsConfig `json:"tools,omitempty" yaml:"tools,omitempty"`
|
||||||
Defaults *MusicDefaultsConfig `json:"defaults,omitempty" yaml:"defaults,omitempty"`
|
Defaults *MusicDefaultsConfig `json:"defaults,omitempty" yaml:"defaults,omitempty"`
|
||||||
Datasets map[string]MusicDatasetConfig `json:"datasets,omitempty" yaml:"datasets,omitempty"`
|
Datasets map[string]MusicDatasetConfig `json:"datasets,omitempty" yaml:"datasets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MusicToolsConfig struct {
|
||||||
|
FFmpeg string `json:"ffmpeg,omitempty" yaml:"ffmpeg,omitempty"`
|
||||||
|
FFprobe string `json:"ffprobe,omitempty" yaml:"ffprobe,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type MusicDefaultsConfig struct {
|
type MusicDefaultsConfig struct {
|
||||||
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
||||||
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
||||||
CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"`
|
CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"`
|
||||||
MaxStemLength *int `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"`
|
MaxStemLength *int `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"`
|
||||||
|
NamingScheme string `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"`
|
||||||
|
OutputExtension string `json:"output_extension,omitempty" yaml:"output_extension,omitempty"`
|
||||||
|
ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MusicDatasetConfig struct {
|
type MusicDatasetConfig struct {
|
||||||
Source string `json:"source" yaml:"source"`
|
Source string `json:"source" yaml:"source"`
|
||||||
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
Output string `json:"output,omitempty" yaml:"output,omitempty"`
|
||||||
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
||||||
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
||||||
|
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
||||||
|
NamingScheme string `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"`
|
||||||
|
OutputExtension string `json:"output_extension,omitempty" yaml:"output_extension,omitempty"`
|
||||||
|
PackageMode string `json:"package_mode,omitempty" yaml:"package_mode,omitempty"`
|
||||||
|
ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TopDataConfig struct {
|
type TopDataConfig struct {
|
||||||
@@ -387,6 +438,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
||||||
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
||||||
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
||||||
|
"validation.profile": p.Config.Validation.Profile,
|
||||||
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
||||||
"scripts.cache": p.Config.Scripts.Cache,
|
"scripts.cache": p.Config.Scripts.Cache,
|
||||||
"scripts.compiler.path": p.Config.Scripts.Compiler.Path,
|
"scripts.compiler.path": p.Config.Scripts.Compiler.Path,
|
||||||
@@ -415,6 +467,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
failures = append(failures, validateValidationConfig(p.Config.Validation)...)
|
||||||
effective := p.EffectiveConfig()
|
effective := p.EffectiveConfig()
|
||||||
failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
|
failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
|
||||||
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
||||||
@@ -815,6 +868,25 @@ func (p *Project) CreditsDir() string {
|
|||||||
return p.rootPath(p.EffectiveConfig().Music.CreditsRoot)
|
return p.rootPath(p.EffectiveConfig().Music.CreditsRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Project) MusicToolPath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(p.Root, filepath.FromSlash(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Project) MusicFFmpegPath() string {
|
||||||
|
return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFmpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Project) MusicFFprobePath() string {
|
||||||
|
return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFprobe)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Project) AutogenCacheDir() string {
|
func (p *Project) AutogenCacheDir() string {
|
||||||
return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
|
return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
|
||||||
}
|
}
|
||||||
@@ -877,6 +949,9 @@ func defaultConfig() Config {
|
|||||||
func normalizeConfig(cfg *Config) {
|
func normalizeConfig(cfg *Config) {
|
||||||
cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
|
cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
|
||||||
cfg.Inventory.AssetExtensions = normalizeExtensionSlice(cfg.Inventory.AssetExtensions)
|
cfg.Inventory.AssetExtensions = normalizeExtensionSlice(cfg.Inventory.AssetExtensions)
|
||||||
|
cfg.Validation.BuiltinScriptPrefixes = normalizeLowerStringSlice(cfg.Validation.BuiltinScriptPrefixes)
|
||||||
|
cfg.Validation.Profile = strings.ToLower(strings.TrimSpace(cfg.Validation.Profile))
|
||||||
|
cfg.Validation.RequiredFields = normalizeRequiredFields(cfg.Validation.RequiredFields)
|
||||||
for i := range cfg.HAKs {
|
for i := range cfg.HAKs {
|
||||||
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
|
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
|
||||||
}
|
}
|
||||||
@@ -891,6 +966,10 @@ func normalizeConfig(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeMusicConfig(music *MusicConfig) {
|
func normalizeMusicConfig(music *MusicConfig) {
|
||||||
|
if music.Defaults != nil {
|
||||||
|
music.Defaults.ConvertExtensions = normalizeExtensionSlice(music.Defaults.ConvertExtensions)
|
||||||
|
music.Defaults.OutputExtension = normalizeExtension(music.Defaults.OutputExtension)
|
||||||
|
}
|
||||||
if len(music.Prefixes) > 0 && len(music.Datasets) == 0 {
|
if len(music.Prefixes) > 0 && len(music.Datasets) == 0 {
|
||||||
datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes))
|
datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes))
|
||||||
for dir, prefix := range music.Prefixes {
|
for dir, prefix := range music.Prefixes {
|
||||||
@@ -906,6 +985,13 @@ func normalizeMusicConfig(music *MusicConfig) {
|
|||||||
}
|
}
|
||||||
music.Datasets = datasets
|
music.Datasets = datasets
|
||||||
}
|
}
|
||||||
|
for id, ds := range music.Datasets {
|
||||||
|
ds.Source = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source)))
|
||||||
|
ds.Output = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output)))
|
||||||
|
ds.OutputExtension = normalizeExtension(ds.OutputExtension)
|
||||||
|
ds.ConvertExtensions = normalizeExtensionSlice(ds.ConvertExtensions)
|
||||||
|
music.Datasets[id] = ds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeStringSlice(input []string) []string {
|
func normalizeStringSlice(input []string) []string {
|
||||||
@@ -935,6 +1021,108 @@ func normalizeExtensionSlice(input []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExtension(input string) string {
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
|
||||||
|
trimmed = "." + trimmed
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLowerStringSlice(input []string) []string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(input))
|
||||||
|
for _, value := range input {
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRequiredFields(input map[string][]string) map[string][]string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
out := make(map[string][]string, len(input))
|
||||||
|
for extension, labels := range input {
|
||||||
|
key := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizedLabels := make([]string, 0, len(labels))
|
||||||
|
for _, label := range labels {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label != "" {
|
||||||
|
normalizedLabels = append(normalizedLabels, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(normalizedLabels)
|
||||||
|
out[key] = normalizedLabels
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValidationConfig(cfg ValidationConfig) []error {
|
||||||
|
var failures []error
|
||||||
|
switch strings.TrimSpace(cfg.Profile) {
|
||||||
|
case "", DefaultValidationProfile:
|
||||||
|
default:
|
||||||
|
failures = append(failures, fmt.Errorf("validation.profile %q is not supported", cfg.Profile))
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for index, prefix := range cfg.BuiltinScriptPrefixes {
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if prefix == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not be empty", index))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(prefix, "\x00") {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not contain NUL bytes", index))
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(prefix)
|
||||||
|
if _, exists := seen[normalized]; exists {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] %q is duplicated", index, prefix))
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
}
|
||||||
|
seenExtensions := map[string]struct{}{}
|
||||||
|
for extension, labels := range cfg.RequiredFields {
|
||||||
|
normalizedExtension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
||||||
|
if normalizedExtension == "" {
|
||||||
|
failures = append(failures, errors.New("validation.required_fields contains an empty extension"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(normalizedExtension, "\x00") {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q] must not contain NUL bytes", extension))
|
||||||
|
}
|
||||||
|
if _, exists := seenExtensions[normalizedExtension]; exists {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q] is duplicated after normalization", extension))
|
||||||
|
}
|
||||||
|
seenExtensions[normalizedExtension] = struct{}{}
|
||||||
|
seenLabels := map[string]struct{}{}
|
||||||
|
for index, label := range labels {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not be empty", extension, index))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(label, "\x00") {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not contain NUL bytes", extension, index))
|
||||||
|
}
|
||||||
|
if _, exists := seenLabels[label]; exists {
|
||||||
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] %q is duplicated", extension, index, label))
|
||||||
|
}
|
||||||
|
seenLabels[label] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
func validateAutogenConfig(cfg AutogenConfig) []error {
|
func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
producerIDs := map[string]struct{}{}
|
producerIDs := map[string]struct{}{}
|
||||||
@@ -1014,6 +1202,17 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
|||||||
|
|
||||||
func validateMusicConfig(cfg MusicConfig) []error {
|
func validateMusicConfig(cfg MusicConfig) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
|
if cfg.Defaults != nil {
|
||||||
|
if cfg.Defaults.MaxStemLength != nil && *cfg.Defaults.MaxStemLength < 3 {
|
||||||
|
failures = append(failures, errors.New("music.defaults.max_stem_length must be at least 3"))
|
||||||
|
}
|
||||||
|
if cfg.Defaults.NamingScheme != "" && cfg.Defaults.NamingScheme != "nwn_bmu" && cfg.Defaults.NamingScheme != "passthrough" {
|
||||||
|
failures = append(failures, fmt.Errorf("music.defaults.naming_scheme %q is not supported", cfg.Defaults.NamingScheme))
|
||||||
|
}
|
||||||
|
if cfg.Defaults.OutputExtension != "" && !strings.HasPrefix(cfg.Defaults.OutputExtension, ".") {
|
||||||
|
failures = append(failures, errors.New("music.defaults.output_extension must start with ."))
|
||||||
|
}
|
||||||
|
}
|
||||||
normalizedRoots := map[string]string{}
|
normalizedRoots := map[string]string{}
|
||||||
for root, prefix := range cfg.Prefixes {
|
for root, prefix := range cfg.Prefixes {
|
||||||
normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root)))
|
normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root)))
|
||||||
@@ -1033,19 +1232,41 @@ func validateMusicConfig(cfg MusicConfig) []error {
|
|||||||
for id, ds := range cfg.Datasets {
|
for id, ds := range cfg.Datasets {
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
failures = append(failures, errors.New("music.datasets contains an empty key"))
|
failures = append(failures, errors.New("music.datasets contains an empty key"))
|
||||||
|
} else if strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets id %q must not contain path separators", id))
|
||||||
}
|
}
|
||||||
source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source)))
|
source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source)))
|
||||||
if source == "" {
|
if source == "" {
|
||||||
failures = append(failures, fmt.Errorf("music.datasets[%q].source is required", id))
|
failures = append(failures, fmt.Errorf("music.datasets[%q].source is required", id))
|
||||||
|
} else if pathEscapesRoot(source) {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets[%q].source must not escape project root", id))
|
||||||
} else if prior, exists := seenSources[source]; exists {
|
} else if prior, exists := seenSources[source]; exists {
|
||||||
failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior))
|
failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior))
|
||||||
} else {
|
} else {
|
||||||
seenSources[source] = id
|
seenSources[source] = id
|
||||||
}
|
}
|
||||||
|
output := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output)))
|
||||||
|
if output != "" && pathEscapesRoot(output) {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets[%q].output must not escape project root", id))
|
||||||
|
}
|
||||||
|
if ds.NamingScheme != "" && ds.NamingScheme != "nwn_bmu" && ds.NamingScheme != "passthrough" {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets[%q].naming_scheme %q is not supported", id, ds.NamingScheme))
|
||||||
|
}
|
||||||
|
if ds.PackageMode != "" && ds.PackageMode != "hak_asset" && ds.PackageMode != "none" {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets[%q].package_mode %q is not supported", id, ds.PackageMode))
|
||||||
|
}
|
||||||
|
if ds.OutputExtension != "" && !strings.HasPrefix(ds.OutputExtension, ".") {
|
||||||
|
failures = append(failures, fmt.Errorf("music.datasets[%q].output_extension must start with .", id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathEscapesRoot(path string) bool {
|
||||||
|
clean := filepath.Clean(filepath.FromSlash(path))
|
||||||
|
return clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
func validateGlobList(field string, patterns []string) []error {
|
func validateGlobList(field string, patterns []string) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ paths:
|
|||||||
if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want {
|
if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want {
|
||||||
t.Fatalf("expected default topdata HAK %q, got %q", want, got)
|
t.Fatalf("expected default topdata HAK %q, got %q", want, got)
|
||||||
}
|
}
|
||||||
|
if got, want := strings.Join(effective.Validation.BuiltinScriptPrefixes, ","), "ga_,gc_,gen_,gui_,nw_,nwg_,ta_,x0_,x1_,x2_,x3_"; got != want {
|
||||||
|
t.Fatalf("expected default built-in script prefixes %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" {
|
||||||
|
t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields)
|
||||||
|
}
|
||||||
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
|
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
|
||||||
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
|
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
|
||||||
}
|
}
|
||||||
@@ -77,6 +83,37 @@ paths:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEffectiveConfigHonorsValidationConfig(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
source: src
|
||||||
|
validation:
|
||||||
|
builtin_script_prefixes:
|
||||||
|
- custom_
|
||||||
|
- NW_
|
||||||
|
required_fields:
|
||||||
|
ifo:
|
||||||
|
- Mod_Name
|
||||||
|
- Mod_Hak
|
||||||
|
`)
|
||||||
|
|
||||||
|
proj, err := Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
got := strings.Join(proj.EffectiveConfig().Validation.BuiltinScriptPrefixes, ",")
|
||||||
|
if want := "custom_,nw_"; got != want {
|
||||||
|
t.Fatalf("expected configured validation prefixes %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(proj.EffectiveConfig().Validation.RequiredFields["ifo"], ","); got != "Mod_Hak,Mod_Name" {
|
||||||
|
t.Fatalf("expected configured required fields, got %#v", proj.EffectiveConfig().Validation.RequiredFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
|
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music"
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,24 +50,6 @@ type assetGroupResolver struct {
|
|||||||
defaultGroup string
|
defaultGroup string
|
||||||
}
|
}
|
||||||
|
|
||||||
var requiredFields = map[string][]string{
|
|
||||||
"ifo": {"Mod_Name"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var builtinScriptPrefixes = []string{
|
|
||||||
"nw_",
|
|
||||||
"x0_",
|
|
||||||
"x1_",
|
|
||||||
"x2_",
|
|
||||||
"x3_",
|
|
||||||
"ga_",
|
|
||||||
"gc_",
|
|
||||||
"gen_",
|
|
||||||
"gui_",
|
|
||||||
"nwg_",
|
|
||||||
"ta_",
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateProject(p *project.Project) Report {
|
func ValidateProject(p *project.Project) Report {
|
||||||
report := Report{}
|
report := Report{}
|
||||||
resourceIndex := map[string]string{}
|
resourceIndex := map[string]string{}
|
||||||
@@ -75,6 +58,7 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
assetOccurrences := map[string][]assetOccurrence{}
|
assetOccurrences := map[string][]assetOccurrence{}
|
||||||
documents := make([]loadedDocument, 0, len(p.Inventory.SourceFiles))
|
documents := make([]loadedDocument, 0, len(p.Inventory.SourceFiles))
|
||||||
resolver := newAssetGroupResolver(p)
|
resolver := newAssetGroupResolver(p)
|
||||||
|
effective := p.EffectiveConfig()
|
||||||
|
|
||||||
for _, rel := range p.Inventory.SourceFiles {
|
for _, rel := range p.Inventory.SourceFiles {
|
||||||
if hasUppercaseResourceName(rel) {
|
if hasUppercaseResourceName(rel) {
|
||||||
@@ -101,7 +85,7 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
resourceIndex[key] = rel
|
resourceIndex[key] = rel
|
||||||
}
|
}
|
||||||
|
|
||||||
validateDocumentStructure(&report, rel, extension, document)
|
validateDocumentStructure(&report, rel, extension, document, effective.Validation.RequiredFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rel := range p.Inventory.ScriptFiles {
|
for _, rel := range p.Inventory.ScriptFiles {
|
||||||
@@ -117,7 +101,8 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rel := range p.Inventory.AssetFiles {
|
for _, rel := range p.Inventory.AssetFiles {
|
||||||
if hasUppercaseResourceName(rel) && !isMusicSourceAsset(rel) {
|
musicSource := isMusicSourceAsset(p, rel)
|
||||||
|
if hasUppercaseResourceName(rel) && !musicSource {
|
||||||
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
||||||
}
|
}
|
||||||
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
||||||
@@ -131,6 +116,9 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
if _, exists := assetIndex[key]; !exists {
|
if _, exists := assetIndex[key]; !exists {
|
||||||
assetIndex[key] = rel
|
assetIndex[key] = rel
|
||||||
}
|
}
|
||||||
|
if musicSource {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
group, err := resolver.groupFor(rel)
|
group, err := resolver.groupFor(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,7 +135,7 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
classifyAssetDuplicates(&report, assetOccurrences)
|
classifyAssetDuplicates(&report, assetOccurrences)
|
||||||
|
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
validateReferences(&report, document, resourceIndex, scriptIndex, assetIndex)
|
validateReferences(&report, document, resourceIndex, scriptIndex, assetIndex, effective.Validation.BuiltinScriptPrefixes)
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(report.Diagnostics, func(a, b Diagnostic) int {
|
slices.SortFunc(report.Diagnostics, func(a, b Diagnostic) int {
|
||||||
@@ -296,7 +284,7 @@ func loadDocument(path string) (gff.Document, string, string, error) {
|
|||||||
return document, resref, extension, nil
|
return document, resref, extension, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDocumentStructure(report *Report, path, extension string, document gff.Document) {
|
func validateDocumentStructure(report *Report, path, extension string, document gff.Document, requiredFields map[string][]string) {
|
||||||
expectedType := strings.ToUpper(extension)
|
expectedType := strings.ToUpper(extension)
|
||||||
if strings.TrimSpace(document.FileType) != "" && strings.TrimSpace(document.FileType) != expectedType {
|
if strings.TrimSpace(document.FileType) != "" && strings.TrimSpace(document.FileType) != expectedType {
|
||||||
report.add(path, fmt.Sprintf("file_type %q does not match expected %q", document.FileType, expectedType), SeverityError)
|
report.add(path, fmt.Sprintf("file_type %q does not match expected %q", document.FileType, expectedType), SeverityError)
|
||||||
@@ -338,7 +326,7 @@ func validateUniqueLabels(report *Report, path, scope string, s gff.Struct) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateReferences(report *Report, document loadedDocument, resources, scripts, assets map[string]string) {
|
func validateReferences(report *Report, document loadedDocument, resources, scripts, assets map[string]string, builtinScriptPrefixes []string) {
|
||||||
walkFields(document.Document.Root, func(field gff.Field) {
|
walkFields(document.Document.Root, func(field gff.Field) {
|
||||||
value, ok := fieldStringValue(field.Value)
|
value, ok := fieldStringValue(field.Value)
|
||||||
if !ok || value == "" {
|
if !ok || value == "" {
|
||||||
@@ -347,7 +335,7 @@ func validateReferences(report *Report, document loadedDocument, resources, scri
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case isScriptField(field.Label):
|
case isScriptField(field.Label):
|
||||||
if isBuiltinScript(value) {
|
if isBuiltinScript(value, builtinScriptPrefixes) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, exists := scripts[strings.ToLower(value)]; !exists {
|
if _, exists := scripts[strings.ToLower(value)]; !exists {
|
||||||
@@ -519,20 +507,31 @@ func hasAsset(name string, extensions []string, assets map[string]string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isMusicSourceAsset(rel string) bool {
|
func isMusicSourceAsset(p *project.Project, rel string) bool {
|
||||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||||
if rel != "envi/music" && !strings.HasPrefix(rel, "envi/music/") {
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
return false
|
effective := p.EffectiveConfig()
|
||||||
|
for _, dataset := range effective.Music.Datasets {
|
||||||
|
if !music.PathIsUnder(dataset.Source, rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, candidate := range dataset.ConvertExtensions {
|
||||||
|
if ext == strings.ToLower(strings.TrimSpace(candidate)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
switch strings.ToLower(filepath.Ext(rel)) {
|
if music.PathIsUnder("envi/music", rel) {
|
||||||
case ".mp3", ".ogg":
|
for _, candidate := range effective.Music.ConvertExtensions {
|
||||||
return true
|
if ext == strings.ToLower(strings.TrimSpace(candidate)) {
|
||||||
default:
|
return true
|
||||||
return false
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBuiltinScript(name string) bool {
|
func isBuiltinScript(name string, builtinScriptPrefixes []string) bool {
|
||||||
lower := strings.ToLower(name)
|
lower := strings.ToLower(name)
|
||||||
for _, prefix := range builtinScriptPrefixes {
|
for _, prefix := range builtinScriptPrefixes {
|
||||||
if strings.HasPrefix(lower, prefix) {
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
|||||||
@@ -199,6 +199,102 @@ func TestValidateProjectWarnsForMissingScriptReferences(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectUsesConfiguredBuiltinScriptPrefixes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "src", "dialogs"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
source: src
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
validation:
|
||||||
|
builtin_script_prefixes:
|
||||||
|
- custom_
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "src", "dialogs", "merchant.dlg.json"), `{
|
||||||
|
"file_type": "DLG ",
|
||||||
|
"file_version": "V3.2",
|
||||||
|
"root": {
|
||||||
|
"struct_type": 0,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"label": "Script",
|
||||||
|
"type": "ResRef",
|
||||||
|
"value": "custom_open_store"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := ValidateProject(p)
|
||||||
|
if report.HasErrors() || report.WarningCount() != 0 {
|
||||||
|
t.Fatalf("expected configured script prefix to suppress warning, got %#v", report.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectUsesConfiguredRequiredFields(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "src", "module"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
source: src
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
validation:
|
||||||
|
required_fields:
|
||||||
|
ifo: []
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
|
||||||
|
"file_type": "IFO ",
|
||||||
|
"file_version": "V3.2",
|
||||||
|
"root": {
|
||||||
|
"struct_type": 0,
|
||||||
|
"fields": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := ValidateProject(p)
|
||||||
|
if report.HasErrors() {
|
||||||
|
t.Fatalf("expected configured required fields to suppress IFO error, got %#v", report.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateProjectReportsDecompiledModelsWithoutFailingValidation(t *testing.T) {
|
func TestValidateProjectReportsDecompiledModelsWithoutFailingValidation(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mustMkdir(t, filepath.Join(root, "src"))
|
mustMkdir(t, filepath.Join(root, "src"))
|
||||||
|
|||||||
Reference in New Issue
Block a user