2210 lines
86 KiB
Go
2210 lines
86 KiB
Go
package project
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
ConfigFile = "nwn-tool.yaml"
|
|
ConfigFileYML = "nwn-tool.yml"
|
|
LegacyConfigFile = "nwn-tool.json"
|
|
)
|
|
|
|
var ConfigFileCandidates = []string{ConfigFile, ConfigFileYML, LegacyConfigFile}
|
|
|
|
var SourceExtensions = []string{
|
|
".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
|
|
".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
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 {
|
|
Root string
|
|
Config Config
|
|
ConfigSource ConfigSource
|
|
Provenance ConfigProvenance
|
|
Inventory Inventory
|
|
}
|
|
|
|
type ConfigSource struct {
|
|
Path string
|
|
Name string
|
|
Format string
|
|
Legacy bool
|
|
}
|
|
|
|
type Config struct {
|
|
Module ModuleConfig `json:"module" yaml:"module"`
|
|
Paths PathConfig `json:"paths" yaml:"paths"`
|
|
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
|
Build BuildConfig `json:"build" yaml:"build"`
|
|
Generated GeneratedConfig `json:"generated_assets" yaml:"generated_assets"`
|
|
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
|
Music MusicConfig `json:"music" yaml:"music"`
|
|
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
|
|
Extract ExtractConfig `json:"extract" yaml:"extract"`
|
|
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
|
|
}
|
|
|
|
type ModuleConfig struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
ResRef string `json:"resref" yaml:"resref"`
|
|
Description string `json:"description" yaml:"description"`
|
|
HAKOrder []string `json:"hak_order" yaml:"hak_order"`
|
|
}
|
|
|
|
type PathConfig struct {
|
|
Source string `json:"source" yaml:"source"`
|
|
Assets string `json:"assets" yaml:"assets"`
|
|
Build string `json:"build" yaml:"build"`
|
|
Cache string `json:"cache" yaml:"cache"`
|
|
Tools string `json:"tools" yaml:"tools"`
|
|
}
|
|
|
|
type OutputConfig struct {
|
|
ModuleArchive string `json:"module_archive" yaml:"module_archive"`
|
|
HAKManifest string `json:"hak_manifest" yaml:"hak_manifest"`
|
|
HAKArchive string `json:"hak_archive" yaml:"hak_archive"`
|
|
}
|
|
|
|
type BuildConfig struct {
|
|
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
|
|
}
|
|
|
|
type GeneratedConfig struct {
|
|
TopData2DA []GeneratedTopData2DAConfig `json:"topdata_2da" yaml:"topdata_2da"`
|
|
}
|
|
|
|
type GeneratedTopData2DAConfig struct {
|
|
ID string `json:"id" yaml:"id"`
|
|
Source string `json:"source" yaml:"source"`
|
|
Output string `json:"output" yaml:"output"`
|
|
IncludeDatasets []string `json:"include_datasets" yaml:"include_datasets"`
|
|
PackageRoot string `json:"package_root" yaml:"package_root"`
|
|
Autogen AutogenConsumerConfig `json:"autogen" yaml:"autogen"`
|
|
}
|
|
|
|
type InventoryConfig struct {
|
|
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
|
|
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
|
|
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 HAKConfig struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
Priority int `json:"priority" yaml:"priority"`
|
|
MaxBytes int64 `json:"max_bytes" yaml:"max_bytes"`
|
|
Split bool `json:"split" yaml:"split"`
|
|
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"`
|
|
Include []string `json:"include" yaml:"include"`
|
|
}
|
|
|
|
type MusicConfig struct {
|
|
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"`
|
|
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 {
|
|
StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
|
|
CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
|
|
CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,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 {
|
|
Source string `json:"source" yaml:"source"`
|
|
Output string `json:"output,omitempty" yaml:"output,omitempty"`
|
|
Prefix string `json:"prefix,omitempty" yaml:"prefix,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 {
|
|
Source string `json:"source" yaml:"source"`
|
|
Build string `json:"build" yaml:"build"`
|
|
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
|
|
Assets string `json:"assets" yaml:"assets"`
|
|
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
|
|
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
|
|
PackageHAK string `json:"package_hak" yaml:"package_hak"`
|
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
|
RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"`
|
|
RowExtensions []TopDataRowExtensionConfig `json:"row_extensions" yaml:"row_extensions"`
|
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
|
}
|
|
|
|
type TopDataRowGenerationConfig struct {
|
|
Dataset string `json:"dataset,omitempty" yaml:"dataset,omitempty"`
|
|
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
MinimumRow int `json:"minimum_row,omitempty" yaml:"minimum_row,omitempty"`
|
|
}
|
|
|
|
type TopDataRowExtensionConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
LevelColumn string `json:"level_column" yaml:"level_column"`
|
|
TargetLevel int `json:"target_level" yaml:"target_level"`
|
|
}
|
|
|
|
type TopDataClassFeatInjectionConfig struct {
|
|
GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"`
|
|
ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"`
|
|
}
|
|
|
|
type TopDataClassFeatGlobalRule struct {
|
|
Feat string `json:"feat" yaml:"feat"`
|
|
List string `json:"list" yaml:"list"`
|
|
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
|
|
OnMenu string `json:"on_menu" yaml:"on_menu"`
|
|
RequirePresent []string `json:"require_present,omitempty" yaml:"require_present,omitempty"`
|
|
UnlessPresent []string `json:"unless_present,omitempty" yaml:"unless_present,omitempty"`
|
|
}
|
|
|
|
type TopDataClassFeatMasterfeatRule struct {
|
|
Masterfeat string `json:"masterfeat" yaml:"masterfeat"`
|
|
List string `json:"list" yaml:"list"`
|
|
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
|
|
OnMenu string `json:"on_menu" yaml:"on_menu"`
|
|
}
|
|
|
|
type TopDataValueEncodingConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Column string `json:"column" yaml:"column"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
Min int `json:"min" yaml:"min"`
|
|
Max int `json:"max" yaml:"max"`
|
|
HexWidth int `json:"hex_width" yaml:"hex_width"`
|
|
}
|
|
|
|
type TopDataValueDefaultConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Column string `json:"column" yaml:"column"`
|
|
Value any `json:"value" yaml:"value"`
|
|
}
|
|
|
|
type TopDataWikiConfig struct {
|
|
OutputRoot string `json:"output_root" yaml:"output_root"`
|
|
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
|
|
StateFile string `json:"state_file" yaml:"state_file"`
|
|
ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"`
|
|
DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"`
|
|
DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
|
|
Source string `json:"source" yaml:"source"`
|
|
Renderer string `json:"renderer" yaml:"renderer"`
|
|
LinkStrategy string `json:"link_strategy" yaml:"link_strategy"`
|
|
NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"`
|
|
TablesFile string `json:"tables_file" yaml:"tables_file"`
|
|
VisibilityFile string `json:"visibility_file" yaml:"visibility_file"`
|
|
DataFile string `json:"data_file" yaml:"data_file"`
|
|
PagePathsFile string `json:"page_paths_file" yaml:"page_paths_file"`
|
|
TemplatesDir string `json:"templates_dir" yaml:"templates_dir"`
|
|
PageTemplatesDir string `json:"page_templates_dir" yaml:"page_templates_dir"`
|
|
ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"`
|
|
ManualSectionsFile string `json:"manual_sections_file" yaml:"manual_sections_file"`
|
|
PageIndexFile string `json:"page_index_file" yaml:"page_index_file"`
|
|
AlignmentLinks TopDataWikiAlignmentLinks `json:"alignment_links" yaml:"alignment_links"`
|
|
TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"`
|
|
StatusPages *bool `json:"status_pages,omitempty" yaml:"status_pages,omitempty"`
|
|
StatusListingScope string `json:"status_listing_scope" yaml:"status_listing_scope"`
|
|
StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"`
|
|
ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"`
|
|
}
|
|
|
|
type TopDataWikiAlignmentLinks struct {
|
|
TargetPattern string `json:"target_pattern" yaml:"target_pattern"`
|
|
LinkFormat string `json:"link_format" yaml:"link_format"`
|
|
}
|
|
|
|
type TopDataWikiStalePagesConfig struct {
|
|
Default string `json:"default" yaml:"default"`
|
|
LiveCleanup string `json:"live_cleanup" yaml:"live_cleanup"`
|
|
}
|
|
|
|
type TopDataWikiManagedRegionConfig struct {
|
|
MarkerFormat string `json:"marker_format" yaml:"marker_format"`
|
|
PageMarkerPrefix string `json:"page_marker_prefix" yaml:"page_marker_prefix"`
|
|
}
|
|
|
|
type ExtractConfig struct {
|
|
IgnoreExtensions []string `json:"ignore_extensions" yaml:"ignore_extensions"`
|
|
Archives []string `json:"archives,omitempty" yaml:"archives,omitempty"`
|
|
Layout string `json:"layout" yaml:"layout"`
|
|
HAKDiscovery string `json:"hak_discovery" yaml:"hak_discovery"`
|
|
CleanupStale *bool `json:"cleanup_stale,omitempty" yaml:"cleanup_stale,omitempty"`
|
|
ConsumeArchives *bool `json:"consume_archives,omitempty" yaml:"consume_archives,omitempty"`
|
|
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
|
|
Merge ExtractMergeConfig `json:"merge" yaml:"merge"`
|
|
}
|
|
|
|
type ExtractMergeConfig struct {
|
|
GFFJSON []ExtractGFFJSONMergeRule `json:"gff_json" yaml:"gff_json"`
|
|
}
|
|
|
|
type ExtractGFFJSONMergeRule struct {
|
|
Target string `json:"target" yaml:"target"`
|
|
PreserveFields []string `json:"preserve_fields" yaml:"preserve_fields"`
|
|
MergeLists []ExtractListMergeRule `json:"merge_lists" yaml:"merge_lists"`
|
|
}
|
|
|
|
type ExtractListMergeRule struct {
|
|
Field string `json:"field" yaml:"field"`
|
|
KeyField string `json:"key_field" yaml:"key_field"`
|
|
Strategy string `json:"strategy" yaml:"strategy"`
|
|
}
|
|
|
|
type AutogenConfig struct {
|
|
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
|
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
|
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
|
|
ReleaseSource AutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
|
|
}
|
|
|
|
type AutogenCacheConfig struct {
|
|
Root string `json:"root" yaml:"root"`
|
|
MaxAge string `json:"max_age" yaml:"max_age"`
|
|
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
|
}
|
|
|
|
type AutogenReleaseSourceConfig struct {
|
|
Provider string `json:"provider" yaml:"provider"`
|
|
ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
|
|
RepoEnv string `json:"repo_env" yaml:"repo_env"`
|
|
}
|
|
|
|
type AutogenProducerConfig struct {
|
|
ID string `json:"id" yaml:"id"`
|
|
Root string `json:"root" yaml:"root"`
|
|
Include []string `json:"include" yaml:"include"`
|
|
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
|
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
|
AccessoryVisualeffects AccessoryVisualeffectsConfig `json:"accessory_visualeffects" yaml:"accessory_visualeffects"`
|
|
}
|
|
|
|
type AutogenConsumerConfig struct {
|
|
ID string `json:"id" yaml:"id"`
|
|
Producer string `json:"producer" yaml:"producer"`
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"`
|
|
Root string `json:"root" yaml:"root"`
|
|
Include []string `json:"include" yaml:"include"`
|
|
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
|
PartsRows PartsRowsConfig `json:"parts_rows" yaml:"parts_rows"`
|
|
AccessoryVisualeffects AccessoryVisualeffectsConfig `json:"accessory_visualeffects" yaml:"accessory_visualeffects"`
|
|
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
|
LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
|
|
ManifestFile string `json:"manifest_file,omitempty" yaml:"manifest_file,omitempty"`
|
|
Source AutogenSourceConfig `json:"source,omitempty" yaml:"source,omitempty"`
|
|
}
|
|
|
|
type AccessoryVisualeffectsConfig struct {
|
|
Groups map[string]AccessoryVisualeffectGroupConfig `json:"groups,omitempty" yaml:"groups"`
|
|
GroupTokenSource string `json:"group_token_source,omitempty" yaml:"group_token_source"`
|
|
CategoryFrom string `json:"category_from,omitempty" yaml:"category_from"`
|
|
Delimiter string `json:"delimiter,omitempty" yaml:"delimiter"`
|
|
Case string `json:"case,omitempty" yaml:"case"`
|
|
StripModelPrefixes []string `json:"strip_model_prefixes,omitempty" yaml:"strip_model_prefixes"`
|
|
KeyFormat string `json:"key_format,omitempty" yaml:"key_format"`
|
|
LabelFormat string `json:"label_format,omitempty" yaml:"label_format"`
|
|
ModelColumn string `json:"model_column,omitempty" yaml:"model_column"`
|
|
RowDefaults map[string]string `json:"row_defaults,omitempty" yaml:"row_defaults"`
|
|
}
|
|
|
|
type AccessoryVisualeffectGroupConfig struct {
|
|
Prefix string `json:"prefix" yaml:"prefix"`
|
|
ModelColumn string `json:"model_column,omitempty" yaml:"model_column"`
|
|
ModelColumns []string `json:"model_columns,omitempty" yaml:"model_columns"`
|
|
RowDefaults map[string]string `json:"row_defaults,omitempty" yaml:"row_defaults"`
|
|
}
|
|
|
|
type PartsRowsConfig struct {
|
|
RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"`
|
|
ACBonus PartsRowsACBonusConfig `json:"acbonus" yaml:"acbonus"`
|
|
Datasets map[string]PartsRowsDatasetConfig `json:"datasets" yaml:"datasets"`
|
|
}
|
|
|
|
type PartsRowsDatasetConfig struct {
|
|
ACBonus PartsRowsACBonusPolicy `json:"acbonus" yaml:"acbonus"`
|
|
}
|
|
|
|
type PartsRowsACBonusConfig struct {
|
|
Default PartsRowsACBonusPolicy `json:"default" yaml:"default"`
|
|
Datasets map[string]PartsRowsACBonusPolicy `json:"datasets" yaml:"datasets"`
|
|
}
|
|
|
|
type PartsRowsACBonusPolicy struct {
|
|
Strategy string `json:"strategy" yaml:"strategy"`
|
|
MaxRowID int `json:"max_row_id" yaml:"max_row_id"`
|
|
Divisor int `json:"divisor" yaml:"divisor"`
|
|
Format string `json:"format" yaml:"format"`
|
|
Value string `json:"value" yaml:"value"`
|
|
}
|
|
|
|
type AutogenDeriveConfig struct {
|
|
Kind string `json:"kind" yaml:"kind"`
|
|
GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
|
|
}
|
|
|
|
type AutogenSourceConfig struct {
|
|
Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
|
|
CDNBase string `json:"cdn_base,omitempty" yaml:"cdn_base,omitempty"`
|
|
CDNBaseEnv string `json:"cdn_base_env,omitempty" yaml:"cdn_base_env,omitempty"`
|
|
ChannelsPath string `json:"channels_path,omitempty" yaml:"channels_path,omitempty"`
|
|
ManifestPath string `json:"manifest_path,omitempty" yaml:"manifest_path,omitempty"`
|
|
ReleaseMarkerPath string `json:"release_marker_path,omitempty" yaml:"release_marker_path,omitempty"`
|
|
ChannelEnv string `json:"channel_env,omitempty" yaml:"channel_env,omitempty"`
|
|
OfflineOverrideEnv string `json:"offline_override_env,omitempty" yaml:"offline_override_env,omitempty"`
|
|
}
|
|
|
|
type AutogenManifestConfig struct {
|
|
ReleaseTag string `json:"release_tag" yaml:"release_tag"`
|
|
AssetName string `json:"asset_name" yaml:"asset_name"`
|
|
CacheName string `json:"cache_name" yaml:"cache_name"`
|
|
}
|
|
|
|
type Inventory struct {
|
|
SourceFiles []string
|
|
ScriptFiles []string
|
|
AssetFiles []string
|
|
Extensions []string
|
|
}
|
|
|
|
type InventoryReport struct {
|
|
SourceFiles int
|
|
ScriptFiles int
|
|
AssetFiles int
|
|
Extensions []string
|
|
}
|
|
|
|
func FindRoot(start string) (string, error) {
|
|
current := start
|
|
for {
|
|
for _, name := range ConfigFileCandidates {
|
|
candidate := filepath.Join(current, name)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return current, nil
|
|
}
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return "", fmt.Errorf("could not find %s, %s, or legacy %s from %s", ConfigFile, ConfigFileYML, LegacyConfigFile, start)
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func Load(root string) (*Project, error) {
|
|
source, err := findConfigSource(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := os.ReadFile(source.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", source.Name, err)
|
|
}
|
|
|
|
cfg := defaultConfig()
|
|
provenance, err := configProvenance(raw, source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source.Format == "yaml" {
|
|
decoder := yaml.NewDecoder(bytes.NewReader(raw))
|
|
decoder.KnownFields(true)
|
|
if err := decoder.Decode(&cfg); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", source.Name, err)
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
return nil, fmt.Errorf("parse %s: multiple YAML documents are not supported", source.Name)
|
|
}
|
|
} else {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&cfg); err != nil {
|
|
return nil, fmt.Errorf("parse legacy %s: %w", source.Name, err)
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
return nil, fmt.Errorf("parse legacy %s: multiple JSON documents are not supported", source.Name)
|
|
}
|
|
}
|
|
normalizeConfig(&cfg)
|
|
|
|
return &Project{
|
|
Root: root,
|
|
Config: cfg,
|
|
ConfigSource: source,
|
|
Provenance: provenance,
|
|
}, nil
|
|
}
|
|
|
|
func findConfigSource(root string) (ConfigSource, error) {
|
|
for _, name := range ConfigFileCandidates {
|
|
path := filepath.Join(root, name)
|
|
if _, err := os.Stat(path); err == nil {
|
|
format := "yaml"
|
|
legacy := false
|
|
if name == LegacyConfigFile {
|
|
format = "json"
|
|
legacy = true
|
|
}
|
|
return ConfigSource{
|
|
Path: path,
|
|
Name: name,
|
|
Format: format,
|
|
Legacy: legacy,
|
|
}, nil
|
|
}
|
|
}
|
|
return ConfigSource{}, fmt.Errorf("could not find %s, %s, or legacy %s in %s", ConfigFile, ConfigFileYML, LegacyConfigFile, root)
|
|
}
|
|
|
|
func configProvenance(raw []byte, source ConfigSource) (ConfigProvenance, error) {
|
|
var data any
|
|
if source.Format == "yaml" {
|
|
if err := yaml.Unmarshal(raw, &data); err != nil {
|
|
return nil, fmt.Errorf("parse %s for provenance: %w", source.Name, err)
|
|
}
|
|
} else {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, fmt.Errorf("parse legacy %s for provenance: %w", source.Name, err)
|
|
}
|
|
}
|
|
provenance := ConfigProvenance{}
|
|
sourceName := "yaml"
|
|
if source.Legacy {
|
|
sourceName = "legacy json"
|
|
}
|
|
recordProvenance(provenance, "", data, ConfigValueProvenance{
|
|
Source: sourceName,
|
|
Detail: source.Name,
|
|
Deprecated: source.Legacy,
|
|
})
|
|
return provenance, nil
|
|
}
|
|
|
|
func recordProvenance(provenance ConfigProvenance, prefix string, value any, source ConfigValueProvenance) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, nested := range typed {
|
|
next := key
|
|
if prefix != "" {
|
|
next = prefix + "." + key
|
|
}
|
|
recordProvenance(provenance, next, nested, source)
|
|
}
|
|
case []any:
|
|
if prefix != "" {
|
|
provenance[prefix] = source
|
|
}
|
|
default:
|
|
if prefix != "" {
|
|
provenance[prefix] = source
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Project) ValidateLayout() error {
|
|
var failures []error
|
|
|
|
if strings.TrimSpace(p.Config.Module.Name) == "" {
|
|
failures = append(failures, errors.New("module.name is required"))
|
|
}
|
|
if strings.TrimSpace(p.Config.Module.ResRef) == "" {
|
|
failures = append(failures, errors.New("module.resref is required"))
|
|
}
|
|
if len(p.Config.Module.ResRef) > 16 {
|
|
failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
|
|
}
|
|
if strings.TrimSpace(p.Config.Paths.Source) == "" && strings.TrimSpace(p.Config.Paths.Assets) == "" && !p.HasTopData() {
|
|
failures = append(failures, errors.New("at least one of paths.source, paths.assets, or topdata.source is required"))
|
|
}
|
|
for field, value := range map[string]string{
|
|
"paths.source": p.Config.Paths.Source,
|
|
"paths.assets": p.Config.Paths.Assets,
|
|
"paths.build": p.Config.Paths.Build,
|
|
"paths.cache": p.Config.Paths.Cache,
|
|
"paths.tools": p.Config.Paths.Tools,
|
|
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
|
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
|
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
|
"generated_assets.topdata_2da": "",
|
|
"validation.profile": p.Config.Validation.Profile,
|
|
"topdata.source": p.Config.TopData.Source,
|
|
"topdata.build": p.Config.TopData.Build,
|
|
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
|
|
"topdata.assets": p.Config.TopData.Assets,
|
|
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
|
|
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
|
|
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
|
|
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
|
|
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
|
|
"topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest,
|
|
"topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary,
|
|
"topdata.wiki.source": p.Config.TopData.Wiki.Source,
|
|
"topdata.wiki.namespaces_file": p.Config.TopData.Wiki.NamespacesFile,
|
|
"topdata.wiki.tables_file": p.Config.TopData.Wiki.TablesFile,
|
|
"topdata.wiki.visibility_file": p.Config.TopData.Wiki.VisibilityFile,
|
|
"topdata.wiki.data_file": p.Config.TopData.Wiki.DataFile,
|
|
"topdata.wiki.page_paths_file": p.Config.TopData.Wiki.PagePathsFile,
|
|
"topdata.wiki.templates_dir": p.Config.TopData.Wiki.TemplatesDir,
|
|
"topdata.wiki.page_templates_dir": p.Config.TopData.Wiki.PageTemplatesDir,
|
|
"topdata.wiki.manual_sections_dir": p.Config.TopData.Wiki.ManualSectionsDir,
|
|
"topdata.wiki.manual_sections_file": p.Config.TopData.Wiki.ManualSectionsFile,
|
|
"topdata.wiki.page_index_file": p.Config.TopData.Wiki.PageIndexFile,
|
|
"topdata.wiki.alignment_links.link_format": p.Config.TopData.Wiki.AlignmentLinks.LinkFormat,
|
|
"extract.layout": p.Config.Extract.Layout,
|
|
"extract.hak_discovery": p.Config.Extract.HAKDiscovery,
|
|
"autogen.cache.root": p.Config.Autogen.Cache.Root,
|
|
"autogen.cache.max_age": p.Config.Autogen.Cache.MaxAge,
|
|
"autogen.cache.refresh_env": p.Config.Autogen.Cache.RefreshEnv,
|
|
"autogen.release_source.provider": p.Config.Autogen.ReleaseSource.Provider,
|
|
"autogen.release_source.server_url_env": p.Config.Autogen.ReleaseSource.ServerURLEnv,
|
|
"autogen.release_source.repo_env": p.Config.Autogen.ReleaseSource.RepoEnv,
|
|
} {
|
|
if strings.Contains(value, "\x00") {
|
|
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
|
}
|
|
}
|
|
failures = append(failures, validateValidationConfig(p.Config.Validation)...)
|
|
effective := p.EffectiveConfig()
|
|
failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
|
|
failures = append(failures, validateTreeRootPath("paths.source", effective.Paths.Source)...)
|
|
failures = append(failures, validateTreeRootPath("paths.assets", effective.Paths.Assets)...)
|
|
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
|
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
|
|
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
|
|
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
|
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
|
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
|
|
failures = append(failures, validateTopDataRowGeneration(effective.TopData.RowGeneration)...)
|
|
failures = append(failures, validateTopDataRowExtensions(effective.TopData.RowExtensions)...)
|
|
failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...)
|
|
failures = append(failures, validateAutogenConsumerSources(effective.Autogen.Consumers)...)
|
|
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
|
failures = append(failures, validateRelativePath("topdata.compiled_tlk", effective.TopData.CompiledTLK)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.output_root", effective.TopData.Wiki.OutputRoot)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.pages_dir", effective.TopData.Wiki.PagesDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.state_file", effective.TopData.Wiki.StateFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.source", effective.TopData.Wiki.Source)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.namespaces_file", effective.TopData.Wiki.NamespacesFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.tables_file", effective.TopData.Wiki.TablesFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.visibility_file", effective.TopData.Wiki.VisibilityFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.data_file", effective.TopData.Wiki.DataFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_paths_file", effective.TopData.Wiki.PagePathsFile)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_templates_dir", effective.TopData.Wiki.PageTemplatesDir)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.manual_sections_file", effective.TopData.Wiki.ManualSectionsFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_index_file", effective.TopData.Wiki.PageIndexFile)...)
|
|
failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
|
|
if err := validateOutputFileName("outputs.module_archive", filepath.Base(effective.Outputs.ModuleArchive), ".mod"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("outputs.hak_manifest", filepath.Base(effective.Outputs.HAKManifest), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.wiki.deploy_manifest", filepath.Base(effective.TopData.Wiki.DeployManifest), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.wiki.page_index_file", filepath.Base(effective.TopData.Wiki.PageIndexFile), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
switch effective.TopData.Wiki.Renderer {
|
|
case "nodebb_tiptap_html":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.renderer %q is not supported", effective.TopData.Wiki.Renderer))
|
|
}
|
|
switch effective.TopData.Wiki.LinkStrategy {
|
|
case "preserve_westgate_wiki_links":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.link_strategy %q is not supported", effective.TopData.Wiki.LinkStrategy))
|
|
}
|
|
switch effective.TopData.Wiki.AlignmentLinks.LinkFormat {
|
|
case "wiki_markup", "html_anchor":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.alignment_links.link_format %q is not supported", effective.TopData.Wiki.AlignmentLinks.LinkFormat))
|
|
}
|
|
if effective.TopData.Wiki.TitlePrefixMinLength < 1 {
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.title_prefix_min_length must be at least 1"))
|
|
}
|
|
switch effective.TopData.Wiki.StatusListingScope {
|
|
case "all", "namespace":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.status_listing_scope %q is not supported", effective.TopData.Wiki.StatusListingScope))
|
|
}
|
|
for key, policy := range map[string]string{
|
|
"topdata.wiki.stale_pages.default": effective.TopData.Wiki.StalePages.Default,
|
|
"topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup,
|
|
} {
|
|
switch policy {
|
|
case "report", "archive", "purge":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
|
|
}
|
|
}
|
|
switch effective.Extract.Layout {
|
|
case "nwn_canonical_json":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("extract.layout %q is not supported", effective.Extract.Layout))
|
|
}
|
|
switch effective.Extract.HAKDiscovery {
|
|
case "build_glob", "configured_haks":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("extract.hak_discovery %q is not supported", effective.Extract.HAKDiscovery))
|
|
}
|
|
failures = append(failures, validateGlobList("extract.archives", effective.Extract.Archives)...)
|
|
failures = append(failures, validateExtractMergeConfig(effective.Extract.Merge)...)
|
|
if _, err := time.ParseDuration(effective.Autogen.Cache.MaxAge); err != nil {
|
|
failures = append(failures, fmt.Errorf("autogen.cache.max_age must be a duration: %w", err))
|
|
}
|
|
if effective.Autogen.ReleaseSource.Provider != "gitea" {
|
|
failures = append(failures, fmt.Errorf("autogen.release_source.provider %q is not supported", effective.Autogen.ReleaseSource.Provider))
|
|
}
|
|
if p.HasTopData() {
|
|
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.package_tlk", p.TopDataPackageTLKName(), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.compiled_tlk", filepath.Base(p.TopDataCompiledTLKPath()), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
failures = append(failures, validateAutogenConfig(p.Config.Autogen)...)
|
|
failures = append(failures, validateMusicConfig(p.Config.Music)...)
|
|
|
|
hakNames := map[string]struct{}{}
|
|
for index, hak := range p.Config.HAKs {
|
|
name := strings.TrimSpace(hak.Name)
|
|
if strings.TrimSpace(hak.Name) == "" {
|
|
failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
|
|
} else {
|
|
normalizedName := strings.ToLower(name)
|
|
if _, exists := hakNames[normalizedName]; exists {
|
|
failures = append(failures, fmt.Errorf("haks[%d].name %q is duplicated", index, name))
|
|
}
|
|
hakNames[normalizedName] = struct{}{}
|
|
if err := validateOutputFileName(fmt.Sprintf("haks[%d].name", index), name+".hak", ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
if hak.Priority <= 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
|
|
}
|
|
if hak.MaxBytes < 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
|
|
}
|
|
if len(hak.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
|
|
}
|
|
failures = append(failures, validateGlobList(fmt.Sprintf("haks[%d].include", index), hak.Include)...)
|
|
}
|
|
|
|
for label, pathFn := range map[string]func() string{
|
|
"paths.build": p.BuildDir,
|
|
} {
|
|
path := pathFn()
|
|
if path == "" || filepath.Clean(path) == p.Root {
|
|
continue
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
// An output dir that does not exist yet is fine: the builder creates
|
|
// it (MkdirAll) before writing. A bare clone must validate/build from
|
|
// a clean tree with no pre-step (R2/parity) — only a path that exists
|
|
// but is NOT a directory is a real error.
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
failures = append(failures, fmt.Errorf("%s: %w", label, err))
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
|
|
}
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateTopDataValueEncodings(encodings []TopDataValueEncodingConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, encoding := range encodings {
|
|
prefix := fmt.Sprintf("topdata.value_encodings[%d]", index)
|
|
dataset := strings.TrimSpace(encoding.Dataset)
|
|
column := strings.TrimSpace(encoding.Column)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.column is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(encoding.Mode) {
|
|
case "packed_hex_list", "external_hex_list", "alignment_hex_list", "alignment_set_mask", "id_hex_list":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode))
|
|
}
|
|
if encoding.Min < 0 {
|
|
failures = append(failures, fmt.Errorf("%s.min must be zero or greater", prefix))
|
|
}
|
|
if encoding.Max < encoding.Min {
|
|
failures = append(failures, fmt.Errorf("%s.max must be greater than or equal to min", prefix))
|
|
}
|
|
if encoding.HexWidth <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.hex_width must be greater than zero", prefix))
|
|
} else if encoding.Max >= intPow(16, encoding.HexWidth) {
|
|
failures = append(failures, fmt.Errorf("%s.max does not fit in hex_width %d", prefix, encoding.HexWidth))
|
|
}
|
|
key := filepath.ToSlash(dataset) + "\x00" + strings.ToLower(column)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset/column encoding", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, valueDefault := range defaults {
|
|
prefix := fmt.Sprintf("topdata.value_defaults[%d]", index)
|
|
dataset := strings.TrimSpace(valueDefault.Dataset)
|
|
column := strings.TrimSpace(valueDefault.Column)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.column is required", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset) + "\x00" + strings.ToLower(column)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset/column default", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataRowGeneration(rules []TopDataRowGenerationConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, rule := range rules {
|
|
prefix := fmt.Sprintf("topdata.row_generation[%d]", index)
|
|
dataset := strings.TrimSpace(rule.Dataset)
|
|
namespace := strings.TrimSpace(rule.Namespace)
|
|
if dataset == "" {
|
|
dataset = namespace
|
|
}
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(rule.Mode) {
|
|
case "", "after_base", "first_null_row":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode))
|
|
}
|
|
if rule.MinimumRow < 0 {
|
|
failures = append(failures, fmt.Errorf("%s.minimum_row must be zero or greater", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row generation rule", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataRowExtensions(rules []TopDataRowExtensionConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, rule := range rules {
|
|
prefix := fmt.Sprintf("topdata.row_extensions[%d]", index)
|
|
dataset := strings.TrimSpace(rule.Dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(rule.Mode) {
|
|
case "repeat_last":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode))
|
|
}
|
|
if strings.TrimSpace(rule.LevelColumn) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.level_column is required", prefix))
|
|
}
|
|
if rule.TargetLevel <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.target_level must be greater than zero", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row extension rule", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error {
|
|
failures := []error{}
|
|
for index, rule := range config.GlobalFeats {
|
|
prefix := fmt.Sprintf("topdata.class_feat_injections.global_feats[%d]", index)
|
|
if feat := strings.TrimSpace(rule.Feat); feat == "" {
|
|
failures = append(failures, fmt.Errorf("%s.feat is required", prefix))
|
|
} else if !strings.HasPrefix(feat, "feat:") {
|
|
failures = append(failures, fmt.Errorf("%s.feat must use a feat: reference", prefix))
|
|
}
|
|
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
|
|
failures = append(failures, validateFeatReferenceList(prefix+".require_present", rule.RequirePresent)...)
|
|
failures = append(failures, validateFeatReferenceList(prefix+".unless_present", rule.UnlessPresent)...)
|
|
}
|
|
for index, rule := range config.ClassSkillMasterfeats {
|
|
prefix := fmt.Sprintf("topdata.class_feat_injections.class_skill_masterfeats[%d]", index)
|
|
if masterfeat := strings.TrimSpace(rule.Masterfeat); masterfeat == "" {
|
|
failures = append(failures, fmt.Errorf("%s.masterfeat is required", prefix))
|
|
} else if !strings.HasPrefix(masterfeat, "masterfeats:") {
|
|
failures = append(failures, fmt.Errorf("%s.masterfeat must use a masterfeats: reference", prefix))
|
|
}
|
|
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateClassFeatInjectionFields(prefix, list, grantedOnLevel, onMenu string) []error {
|
|
failures := []error{}
|
|
for field, value := range map[string]string{
|
|
"list": list,
|
|
"granted_on_level": grantedOnLevel,
|
|
"on_menu": onMenu,
|
|
} {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
failures = append(failures, fmt.Errorf("%s.%s is required", prefix, field))
|
|
continue
|
|
}
|
|
if _, err := strconv.Atoi(trimmed); err != nil {
|
|
failures = append(failures, fmt.Errorf("%s.%s must be an integer", prefix, field))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateFeatReferenceList(prefix string, refs []string) []error {
|
|
failures := []error{}
|
|
for index, ref := range refs {
|
|
trimmed := strings.TrimSpace(ref)
|
|
if trimmed == "" {
|
|
failures = append(failures, fmt.Errorf("%s[%d] is required", prefix, index))
|
|
} else if !strings.HasPrefix(trimmed, "feat:") {
|
|
failures = append(failures, fmt.Errorf("%s[%d] must use a feat: reference", prefix, index))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func intPow(base, exponent int) int {
|
|
result := 1
|
|
for i := 0; i < exponent; i++ {
|
|
result *= base
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validateExtractMergeConfig(config ExtractMergeConfig) []error {
|
|
var failures []error
|
|
seenTargets := map[string]struct{}{}
|
|
for ruleIndex, rule := range config.GFFJSON {
|
|
prefix := fmt.Sprintf("extract.merge.gff_json[%d]", ruleIndex)
|
|
target := strings.TrimSpace(rule.Target)
|
|
switch {
|
|
case target == "":
|
|
failures = append(failures, fmt.Errorf("%s.target must not be empty", prefix))
|
|
case filepath.IsAbs(filepath.FromSlash(target)):
|
|
failures = append(failures, fmt.Errorf("%s.target %q must be relative to paths.source", prefix, rule.Target))
|
|
default:
|
|
clean := filepath.Clean(filepath.FromSlash(target))
|
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
failures = append(failures, fmt.Errorf("%s.target %q must not escape paths.source", prefix, rule.Target))
|
|
}
|
|
normalized := filepath.ToSlash(clean)
|
|
if _, exists := seenTargets[normalized]; exists {
|
|
failures = append(failures, fmt.Errorf("extract.merge.gff_json target %q is configured more than once", normalized))
|
|
}
|
|
seenTargets[normalized] = struct{}{}
|
|
}
|
|
|
|
for listIndex, listRule := range rule.MergeLists {
|
|
listPrefix := fmt.Sprintf("%s.merge_lists[%d]", prefix, listIndex)
|
|
if strings.TrimSpace(listRule.Field) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.field must not be empty", listPrefix))
|
|
}
|
|
if strings.TrimSpace(listRule.KeyField) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.key_field must not be empty", listPrefix))
|
|
}
|
|
switch listRule.Strategy {
|
|
case "preserve_existing_order_append_new":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.strategy must not be empty", listPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.strategy %q is not supported", listPrefix, listRule.Strategy))
|
|
}
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func (p *Project) Scan() error {
|
|
var sourceFiles []string
|
|
var sourceExts []string
|
|
effective := p.EffectiveConfig()
|
|
|
|
sourceDir := p.SourceDir()
|
|
if sourceDir != "" && filepath.Clean(sourceDir) != filepath.Clean(p.Root) {
|
|
var err error
|
|
sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
return ext == ".json" || ext == ".nss"
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("scan source tree: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
assetDir := p.AssetsDir()
|
|
var assetFiles []string
|
|
if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) {
|
|
musicSourceExts := make(map[string]struct{})
|
|
for _, ext := range effective.Music.ConvertExtensions {
|
|
musicSourceExts[ext] = struct{}{}
|
|
}
|
|
musicSourceRoots := make(map[string]map[string]struct{})
|
|
for _, ds := range effective.Music.Datasets {
|
|
if strings.TrimSpace(ds.Source) == "" {
|
|
continue
|
|
}
|
|
exts := musicSourceRoots[ds.Source]
|
|
if exts == nil {
|
|
exts = make(map[string]struct{})
|
|
musicSourceRoots[ds.Source] = exts
|
|
}
|
|
for _, ext := range ds.ConvertExtensions {
|
|
exts[ext] = struct{}{}
|
|
}
|
|
for ext := range musicSourceExts {
|
|
exts[ext] = struct{}{}
|
|
}
|
|
}
|
|
var err error
|
|
assetFiles, _, err = scanDir(assetDir, func(path string) bool {
|
|
rel, err := filepath.Rel(assetDir, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
ext := strings.ToLower(filepath.Ext(rel))
|
|
if slices.Contains(effective.Inventory.AssetExtensions, ext) {
|
|
return true
|
|
}
|
|
for root, exts := range musicSourceRoots {
|
|
if !pathIsUnder(root, rel) {
|
|
continue
|
|
}
|
|
_, ok := exts[ext]
|
|
return ok
|
|
}
|
|
return false
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
assetFiles = nil
|
|
} else {
|
|
return fmt.Errorf("scan assets tree: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var scripts []string
|
|
var sources []string
|
|
extensionSet := map[string]struct{}{}
|
|
for _, ext := range sourceExts {
|
|
extensionSet[ext] = struct{}{}
|
|
}
|
|
|
|
for _, path := range sourceFiles {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".nss":
|
|
scripts = append(scripts, path)
|
|
case ".json":
|
|
sources = append(sources, path)
|
|
}
|
|
}
|
|
|
|
extensions := make([]string, 0, len(extensionSet))
|
|
for ext := range extensionSet {
|
|
extensions = append(extensions, ext)
|
|
}
|
|
slices.Sort(extensions)
|
|
|
|
p.Inventory = Inventory{
|
|
SourceFiles: sources,
|
|
ScriptFiles: scripts,
|
|
AssetFiles: assetFiles,
|
|
Extensions: extensions,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Project) SourceDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Source)
|
|
}
|
|
|
|
func (p *Project) AssetsDir() string {
|
|
assets := p.EffectiveConfig().Paths.Assets
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
return p.rootPath(assets)
|
|
}
|
|
|
|
func (p *Project) BuildDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Build)
|
|
}
|
|
|
|
func (p *Project) CacheDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Cache)
|
|
}
|
|
|
|
func (p *Project) ModuleArchivePath() string {
|
|
effective := p.EffectiveConfig()
|
|
return filepath.Join(p.BuildDir(), effective.Outputs.ModuleArchiveName(effective.Module))
|
|
}
|
|
|
|
func (p *Project) HAKManifestPath() string {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKManifest))
|
|
}
|
|
|
|
func (p *Project) HAKArchivePath(name string) string {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKArchiveName(name)))
|
|
}
|
|
|
|
func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
|
|
if len(names) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
wanted := make(map[string]struct{}, len(names))
|
|
for _, name := range names {
|
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
wanted[trimmed] = struct{}{}
|
|
}
|
|
if len(wanted) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
|
|
found := make(map[string]struct{}, len(wanted))
|
|
for _, hak := range p.Config.HAKs {
|
|
key := strings.ToLower(strings.TrimSpace(hak.Name))
|
|
if _, ok := wanted[key]; !ok {
|
|
continue
|
|
}
|
|
filtered = append(filtered, hak)
|
|
found[key] = struct{}{}
|
|
}
|
|
|
|
var missing []string
|
|
for _, name := range names {
|
|
key := strings.TrimSpace(strings.ToLower(name))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, ok := found[key]; !ok {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
clone := *p
|
|
clone.Config = p.Config
|
|
clone.Config.HAKs = filtered
|
|
clone.Inventory = p.Inventory
|
|
return &clone, nil
|
|
}
|
|
|
|
func (p *Project) HasTopData() bool {
|
|
return strings.TrimSpace(p.Config.TopData.Source) != ""
|
|
}
|
|
|
|
func (p *Project) TopDataSourceDir() string {
|
|
if !p.HasTopData() {
|
|
return ""
|
|
}
|
|
return filepath.Join(p.Root, p.Config.TopData.Source)
|
|
}
|
|
|
|
func (p *Project) TopDataBuildDir() string {
|
|
return p.rootPath(p.EffectiveConfig().TopData.Build)
|
|
}
|
|
|
|
func (p *Project) TopDataReferenceBuilderDir() string {
|
|
ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
|
|
if ref == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(ref) {
|
|
return ref
|
|
}
|
|
return filepath.Join(p.Root, ref)
|
|
}
|
|
|
|
func (p *Project) TopDataAssetsDir() string {
|
|
assets := strings.TrimSpace(p.Config.TopData.Assets)
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(assets) {
|
|
return assets
|
|
}
|
|
absPath := filepath.Join(p.Root, assets)
|
|
if _, err := os.Stat(absPath); err == nil {
|
|
return absPath
|
|
}
|
|
return absPath
|
|
}
|
|
|
|
func (p *Project) TopDataUsesRepoCache() bool {
|
|
return sameCleanPath(p.TopDataBuildDir(), p.CacheDir())
|
|
}
|
|
|
|
func (p *Project) TopDataCompiled2DADir() string {
|
|
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Compiled2DADir))
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKPath() string {
|
|
tlkName := p.EffectiveConfig().TopData.CompiledTLK
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(tlkName))
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(tlkName))
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKDir() string {
|
|
return filepath.Dir(p.TopDataCompiledTLKPath())
|
|
}
|
|
|
|
func (p *Project) TopDataWikiRootDir() string {
|
|
wikiRoot := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.OutputRoot)
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.CacheDir(), wikiRoot)
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), wikiRoot)
|
|
}
|
|
|
|
func (p *Project) TopDataWikiSourceDir() string {
|
|
source := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.Source)
|
|
if filepath.IsAbs(source) {
|
|
return source
|
|
}
|
|
return filepath.Join(p.Root, source)
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPagesDir() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagesDir))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiStatePath() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.StateFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPageIndexPath() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PageIndexFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiDataPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.DataFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPagePathsPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagePathsFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPageTemplatesDir() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PageTemplatesDir))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiManualSectionsPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.ManualSectionsFile))
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKName() string {
|
|
return p.EffectiveConfig().TopData.PackageHAK
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKName() string {
|
|
return p.EffectiveConfig().TopData.PackageTLK
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageHAKName())
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageTLKName())
|
|
}
|
|
|
|
func (p *Project) MusicStageDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Music.StageRoot)
|
|
}
|
|
|
|
func (p *Project) CreditsDir() string {
|
|
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 {
|
|
return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
|
|
}
|
|
|
|
func (p *Project) AutogenCachePath(name string) string {
|
|
return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
|
|
}
|
|
|
|
func (p *Project) AutogenCacheMaxAge() time.Duration {
|
|
duration, err := time.ParseDuration(p.EffectiveConfig().Autogen.Cache.MaxAge)
|
|
if err != nil {
|
|
return DefaultAutogenCacheMaxAge
|
|
}
|
|
return duration
|
|
}
|
|
|
|
func (p *Project) AutogenRefreshEnv() string {
|
|
return p.EffectiveConfig().Autogen.Cache.RefreshEnv
|
|
}
|
|
|
|
func (p *Project) PartsManifestRefreshEnv() string {
|
|
return p.EffectiveConfig().Autogen.Cache.PartsManifestRefreshEnv
|
|
}
|
|
|
|
func (p *Project) AssetsServerURLEnv() string {
|
|
return p.EffectiveConfig().Autogen.ReleaseSource.ServerURLEnv
|
|
}
|
|
|
|
func (p *Project) AssetsRepoEnv() string {
|
|
return p.EffectiveConfig().Autogen.ReleaseSource.RepoEnv
|
|
}
|
|
|
|
func (p *Project) rootPath(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 (i Inventory) Report() InventoryReport {
|
|
return InventoryReport{
|
|
SourceFiles: len(i.SourceFiles),
|
|
ScriptFiles: len(i.ScriptFiles),
|
|
AssetFiles: len(i.AssetFiles),
|
|
Extensions: i.Extensions,
|
|
}
|
|
}
|
|
|
|
func defaultConfig() Config {
|
|
return Config{
|
|
Paths: PathConfig{
|
|
Build: DefaultBuildPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
func normalizeConfig(cfg *Config) {
|
|
cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
|
|
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 {
|
|
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
|
|
}
|
|
cfg.Extract.IgnoreExtensions = normalizeStringSlice(cfg.Extract.IgnoreExtensions)
|
|
cfg.Extract.Archives = normalizeStringSlice(cfg.Extract.Archives)
|
|
for i := range cfg.Extract.Merge.GFFJSON {
|
|
target := strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].Target)
|
|
if target != "" {
|
|
target = filepath.ToSlash(filepath.Clean(filepath.FromSlash(target)))
|
|
}
|
|
cfg.Extract.Merge.GFFJSON[i].Target = target
|
|
cfg.Extract.Merge.GFFJSON[i].PreserveFields = normalizeStringSlice(cfg.Extract.Merge.GFFJSON[i].PreserveFields)
|
|
for j := range cfg.Extract.Merge.GFFJSON[i].MergeLists {
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field)
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField)
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Strategy = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Strategy)
|
|
}
|
|
}
|
|
for i := range cfg.Autogen.Producers {
|
|
cfg.Autogen.Producers[i].Include = normalizeStringSlice(cfg.Autogen.Producers[i].Include)
|
|
}
|
|
for i := range cfg.Autogen.Consumers {
|
|
cfg.Autogen.Consumers[i].Include = normalizeStringSlice(cfg.Autogen.Consumers[i].Include)
|
|
}
|
|
normalizeMusicConfig(&cfg.Music)
|
|
}
|
|
|
|
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 {
|
|
datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes))
|
|
for dir, prefix := range music.Prefixes {
|
|
source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(dir)))
|
|
if source == "" {
|
|
continue
|
|
}
|
|
key := strings.ReplaceAll(source, "/", "_")
|
|
datasets[key] = MusicDatasetConfig{
|
|
Source: source,
|
|
Prefix: strings.TrimSpace(prefix),
|
|
}
|
|
}
|
|
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 {
|
|
if len(input) == 0 {
|
|
return input
|
|
}
|
|
out := slices.Clone(input)
|
|
slices.SortFunc(out, func(a, b string) int {
|
|
return strings.Compare(strings.TrimSpace(a), strings.TrimSpace(b))
|
|
})
|
|
return out
|
|
}
|
|
|
|
func normalizeExtensionSlice(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 != "" && !strings.HasPrefix(trimmed, ".") {
|
|
trimmed = "." + trimmed
|
|
}
|
|
out = append(out, trimmed)
|
|
}
|
|
slices.Sort(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 {
|
|
var failures []error
|
|
producerIDs := map[string]struct{}{}
|
|
consumerIDs := map[string]struct{}{}
|
|
datasetIDs := map[string]struct{}{}
|
|
|
|
for index, producer := range cfg.Producers {
|
|
fieldPrefix := fmt.Sprintf("autogen.producers[%d]", index)
|
|
id := strings.TrimSpace(producer.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := producerIDs[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
producerIDs[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(producer.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
|
|
}
|
|
if len(producer.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".include", producer.Include)...)
|
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", producer.Derive)...)
|
|
failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", producer.Manifest)...)
|
|
if accessoryVisualeffectsConfigConfigured(producer.AccessoryVisualeffects) {
|
|
failures = append(failures, validateAccessoryVisualeffectsConfig(fieldPrefix+".accessory_visualeffects", producer.AccessoryVisualeffects)...)
|
|
}
|
|
}
|
|
|
|
for index, consumer := range cfg.Consumers {
|
|
fieldPrefix := fmt.Sprintf("autogen.consumers[%d]", index)
|
|
id := strings.TrimSpace(consumer.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := consumerIDs[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
consumerIDs[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(consumer.Producer) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.producer is required", fieldPrefix))
|
|
}
|
|
if strings.TrimSpace(consumer.Dataset) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", fieldPrefix))
|
|
} else {
|
|
dataset := strings.TrimSpace(consumer.Dataset)
|
|
if _, exists := datasetIDs[dataset]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.dataset %q is duplicated", fieldPrefix, dataset))
|
|
}
|
|
datasetIDs[dataset] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(consumer.Producer) != "" {
|
|
if _, exists := producerIDs[strings.TrimSpace(consumer.Producer)]; len(producerIDs) > 0 && !exists {
|
|
failures = append(failures, fmt.Errorf("%s.producer %q does not match a configured producer", fieldPrefix, consumer.Producer))
|
|
}
|
|
}
|
|
switch strings.TrimSpace(consumer.Mode) {
|
|
case "parts_rows", "accessory_visualeffects":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.mode is required", fieldPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", fieldPrefix, consumer.Mode))
|
|
}
|
|
if strings.TrimSpace(consumer.Mode) == "accessory_visualeffects" && accessoryVisualeffectsConfigConfigured(consumer.AccessoryVisualeffects) {
|
|
failures = append(failures, validateAccessoryVisualeffectsConfig(fieldPrefix+".accessory_visualeffects", consumer.AccessoryVisualeffects)...)
|
|
}
|
|
// A manifest_file consumer reads a pre-resolved local manifest, and a
|
|
// cdn_channel consumer resolves entirely from Source.* at runtime, so
|
|
// neither reads the released-source fields (root/include/derive/manifest)
|
|
// at build time — don't require them. See resolveAutogenConsumerManifest.
|
|
if strings.TrimSpace(consumer.ManifestFile) == "" &&
|
|
strings.TrimSpace(consumer.Source.Kind) != "cdn_channel" {
|
|
if strings.TrimSpace(consumer.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
|
|
}
|
|
if len(consumer.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".include", consumer.Include)...)
|
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", consumer.Derive)...)
|
|
failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", consumer.Manifest)...)
|
|
}
|
|
}
|
|
|
|
return failures
|
|
}
|
|
|
|
func validateAutogenConsumerSources(consumers []AutogenConsumerConfig) []error {
|
|
var failures []error
|
|
for _, c := range consumers {
|
|
if strings.TrimSpace(c.Source.Kind) != "cdn_channel" {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(c.ID)
|
|
if label == "" {
|
|
label = "<unnamed>"
|
|
}
|
|
if strings.TrimSpace(c.Source.ChannelsPath) == "" {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.channels_path is required for kind cdn_channel", label))
|
|
}
|
|
manifestPath := strings.TrimSpace(c.Source.ManifestPath)
|
|
if manifestPath == "" {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.manifest_path is required for kind cdn_channel", label))
|
|
} else if !strings.Contains(manifestPath, "{tag}") {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.manifest_path must contain {tag}", label))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateGeneratedConfig(cfg GeneratedConfig) []error {
|
|
var failures []error
|
|
seen := map[string]struct{}{}
|
|
for index, top2da := range cfg.TopData2DA {
|
|
fieldPrefix := fmt.Sprintf("generated_assets.topdata_2da[%d]", index)
|
|
id := strings.TrimSpace(top2da.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := seen[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(top2da.Source) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.source is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".source", top2da.Source)...)
|
|
if strings.TrimSpace(top2da.Output) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.output is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateRelativePath(fieldPrefix+".output", top2da.Output)...)
|
|
if strings.TrimSpace(top2da.PackageRoot) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.package_root is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".package_root", top2da.PackageRoot)...)
|
|
if len(top2da.IncludeDatasets) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.include_datasets must contain at least one pattern", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".include_datasets", top2da.IncludeDatasets)...)
|
|
autogenMode := strings.TrimSpace(top2da.Autogen.Mode)
|
|
switch autogenMode {
|
|
case "parts_rows", "cachedmodels_rows":
|
|
case "":
|
|
continue
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.autogen.mode %q is not supported", fieldPrefix, top2da.Autogen.Mode))
|
|
}
|
|
if strings.TrimSpace(top2da.Autogen.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.autogen.root is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".autogen.root", top2da.Autogen.Root)...)
|
|
if len(top2da.Autogen.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.autogen.include must contain at least one glob", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
|
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
|
|
if autogenMode == "parts_rows" && partsRowsConfigConfigured(top2da.Autogen.PartsRows) {
|
|
failures = append(failures, validatePartsRowsConfig(fieldPrefix+".autogen.parts_rows", top2da.Autogen.PartsRows)...)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func accessoryVisualeffectsConfigConfigured(cfg AccessoryVisualeffectsConfig) bool {
|
|
return len(cfg.Groups) > 0 ||
|
|
strings.TrimSpace(cfg.GroupTokenSource) != "" ||
|
|
strings.TrimSpace(cfg.CategoryFrom) != "" ||
|
|
strings.TrimSpace(cfg.Delimiter) != "" ||
|
|
strings.TrimSpace(cfg.Case) != "" ||
|
|
len(cfg.StripModelPrefixes) > 0 ||
|
|
strings.TrimSpace(cfg.KeyFormat) != "" ||
|
|
strings.TrimSpace(cfg.LabelFormat) != "" ||
|
|
strings.TrimSpace(cfg.ModelColumn) != "" ||
|
|
len(cfg.RowDefaults) > 0
|
|
}
|
|
|
|
func validateAccessoryVisualeffectsConfig(fieldPrefix string, cfg AccessoryVisualeffectsConfig) []error {
|
|
var failures []error
|
|
groupTokenSource := strings.TrimSpace(cfg.GroupTokenSource)
|
|
switch groupTokenSource {
|
|
case "", "prefix", "folder_name":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.group_token_source %q is not supported", fieldPrefix, cfg.GroupTokenSource))
|
|
}
|
|
categoryFrom := strings.TrimSpace(cfg.CategoryFrom)
|
|
switch categoryFrom {
|
|
case "", "none", "immediate_parent", "full_subgroup":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.category_from %q is not supported", fieldPrefix, cfg.CategoryFrom))
|
|
}
|
|
if strings.TrimSpace(cfg.Delimiter) == "" && cfg.Delimiter != "" {
|
|
failures = append(failures, fmt.Errorf("%s.delimiter must not be only whitespace", fieldPrefix))
|
|
}
|
|
if strings.ContainsAny(cfg.Delimiter, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.delimiter must not contain whitespace or NUL bytes", fieldPrefix))
|
|
}
|
|
caseMode := strings.TrimSpace(cfg.Case)
|
|
switch caseMode {
|
|
case "", "preserve", "lower", "upper":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.case %q is not supported", fieldPrefix, cfg.Case))
|
|
}
|
|
for group, groupCfg := range cfg.Groups {
|
|
group = strings.TrimSpace(group)
|
|
if group == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups contains an empty group key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".groups["+group+"]", group)...)
|
|
prefix := strings.TrimSpace(groupCfg.Prefix)
|
|
if prefix == "" && groupTokenSource != "folder_name" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].prefix is required", fieldPrefix, group))
|
|
}
|
|
if strings.ContainsAny(prefix, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].prefix must not contain whitespace or NUL bytes", fieldPrefix, group))
|
|
}
|
|
if column := strings.TrimSpace(groupCfg.ModelColumn); column != "" && strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_column must not contain whitespace or NUL bytes", fieldPrefix, group))
|
|
}
|
|
for index, column := range groupCfg.ModelColumns {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_columns[%d] must not be empty", fieldPrefix, group, index))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_columns[%d] must not contain whitespace or NUL bytes", fieldPrefix, group, index))
|
|
}
|
|
}
|
|
for column := range groupCfg.RowDefaults {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].row_defaults contains an empty column name", fieldPrefix, group))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].row_defaults[%s] column name must not contain whitespace or NUL bytes", fieldPrefix, group, column))
|
|
}
|
|
}
|
|
}
|
|
for index, prefix := range cfg.StripModelPrefixes {
|
|
if strings.TrimSpace(prefix) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.strip_model_prefixes[%d] must not be empty", fieldPrefix, index))
|
|
}
|
|
if strings.Contains(prefix, "\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.strip_model_prefixes[%d] must not contain NUL bytes", fieldPrefix, index))
|
|
}
|
|
}
|
|
if column := strings.TrimSpace(cfg.ModelColumn); column != "" && strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.model_column must not contain whitespace or NUL bytes", fieldPrefix))
|
|
}
|
|
for column := range cfg.RowDefaults {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.row_defaults contains an empty column name", fieldPrefix))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.row_defaults[%s] column name must not contain whitespace or NUL bytes", fieldPrefix, column))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func partsRowsConfigConfigured(cfg PartsRowsConfig) bool {
|
|
if len(cfg.RowDefaults) > 0 {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
|
return true
|
|
}
|
|
if len(cfg.ACBonus.Datasets) > 0 {
|
|
return true
|
|
}
|
|
if len(cfg.Datasets) > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validatePartsRowsConfig(fieldPrefix string, cfg PartsRowsConfig) []error {
|
|
var failures []error
|
|
if partsRowsProjectACBonusConfigured(cfg) {
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.default", cfg.ACBonus.Default, false)...)
|
|
for dataset, policy := range cfg.ACBonus.Datasets {
|
|
dataset = strings.TrimSpace(dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.acbonus.datasets contains an empty dataset key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.datasets."+dataset, policy, true)...)
|
|
}
|
|
for dataset, datasetCfg := range cfg.Datasets {
|
|
dataset = strings.TrimSpace(dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.datasets contains an empty dataset key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".datasets."+dataset+".acbonus", datasetCfg.ACBonus, true)...)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func partsRowsProjectACBonusConfigured(cfg PartsRowsConfig) bool {
|
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
|
return true
|
|
}
|
|
if len(cfg.ACBonus.Datasets) > 0 {
|
|
return true
|
|
}
|
|
for _, dataset := range cfg.Datasets {
|
|
if strings.TrimSpace(dataset.ACBonus.Strategy) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validatePartsRowsACBonusPolicy(fieldPrefix string, policy PartsRowsACBonusPolicy, allowEmpty bool) []error {
|
|
strategy := strings.TrimSpace(policy.Strategy)
|
|
if strategy == "" {
|
|
if allowEmpty {
|
|
return nil
|
|
}
|
|
return []error{fmt.Errorf("%s.strategy is required", fieldPrefix)}
|
|
}
|
|
switch strategy {
|
|
case "ascending_row_id_sort_key", "descending_row_id_sort_key":
|
|
var failures []error
|
|
if strategy == "descending_row_id_sort_key" && policy.MaxRowID <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.max_row_id must be greater than 0", fieldPrefix))
|
|
}
|
|
if policy.Divisor <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.divisor must be greater than 0", fieldPrefix))
|
|
}
|
|
if err := validatePartsRowsFormat(policy.Format); err != nil {
|
|
failures = append(failures, fmt.Errorf("%s.format %q is invalid: %w", fieldPrefix, policy.Format, err))
|
|
}
|
|
return failures
|
|
case "fixed":
|
|
if strings.TrimSpace(policy.Value) == "" {
|
|
return []error{fmt.Errorf("%s.value is required for fixed strategy", fieldPrefix)}
|
|
}
|
|
return nil
|
|
default:
|
|
return []error{fmt.Errorf("%s.strategy %q is not supported", fieldPrefix, policy.Strategy)}
|
|
}
|
|
}
|
|
|
|
func validatePartsRowsFormat(format string) error {
|
|
if strings.TrimSpace(format) == "" {
|
|
return fmt.Errorf("must not be empty")
|
|
}
|
|
formatted := fmt.Sprintf(format, 1.25)
|
|
if strings.Contains(formatted, "%!") {
|
|
return fmt.Errorf("must format a numeric value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateMusicConfig(cfg MusicConfig) []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{}
|
|
for root, prefix := range cfg.Prefixes {
|
|
normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root)))
|
|
if normalizedRoot == "" {
|
|
failures = append(failures, errors.New("music.prefixes contains an empty root"))
|
|
continue
|
|
}
|
|
if prior, exists := normalizedRoots[normalizedRoot]; exists {
|
|
failures = append(failures, fmt.Errorf("music.prefixes roots %q and %q normalize to the same path", prior, root))
|
|
}
|
|
normalizedRoots[normalizedRoot] = root
|
|
if strings.TrimSpace(prefix) == "" {
|
|
failures = append(failures, fmt.Errorf("music.prefixes[%q] must not be empty", root))
|
|
}
|
|
}
|
|
seenSources := map[string]string{}
|
|
for id, ds := range cfg.Datasets {
|
|
if strings.TrimSpace(id) == "" {
|
|
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)))
|
|
if source == "" {
|
|
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 {
|
|
failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior))
|
|
} else {
|
|
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
|
|
}
|
|
|
|
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 {
|
|
var failures []error
|
|
seen := map[string]struct{}{}
|
|
for index, pattern := range patterns {
|
|
trimmed := strings.TrimSpace(pattern)
|
|
if trimmed == "" {
|
|
failures = append(failures, fmt.Errorf("%s[%d] must not be empty", field, index))
|
|
continue
|
|
}
|
|
if strings.Contains(trimmed, "\x00") {
|
|
failures = append(failures, fmt.Errorf("%s[%d] must not contain NUL bytes", field, index))
|
|
}
|
|
if _, exists := seen[trimmed]; exists {
|
|
failures = append(failures, fmt.Errorf("%s[%d] duplicates %q", field, index, trimmed))
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func trimConfigSlashes(path string) string {
|
|
return strings.Trim(path, "/")
|
|
}
|
|
|
|
func validateAutogenDeriveConfig(fieldPrefix string, cfg AutogenDeriveConfig) []error {
|
|
var failures []error
|
|
switch strings.TrimSpace(cfg.Kind) {
|
|
case "trailing_numeric_suffix", "model_stem":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.kind is required", fieldPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.kind %q is not supported", fieldPrefix, cfg.Kind))
|
|
}
|
|
switch strings.TrimSpace(cfg.GroupFrom) {
|
|
case "", "first_path_segment":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.group_from %q is not supported", fieldPrefix, cfg.GroupFrom))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateAutogenManifestConfig(fieldPrefix string, cfg AutogenManifestConfig) []error {
|
|
var failures []error
|
|
if strings.TrimSpace(cfg.ReleaseTag) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.release_tag is required", fieldPrefix))
|
|
}
|
|
if err := validateOutputFileName(fieldPrefix+".asset_name", cfg.AssetName, ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName(fieldPrefix+".cache_name", cfg.CacheName, ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
|
|
var files []string
|
|
extSet := map[string]struct{}{}
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !include(path) {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
files = append(files, rel)
|
|
|
|
if strings.HasSuffix(rel, ".json") {
|
|
base := strings.TrimSuffix(filepath.Base(rel), ".json")
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext != "" {
|
|
extSet[ext] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
slices.Sort(files)
|
|
|
|
exts := make([]string, 0, len(extSet))
|
|
for ext := range extSet {
|
|
exts = append(exts, ext)
|
|
}
|
|
slices.Sort(exts)
|
|
return files, exts, nil
|
|
}
|
|
|
|
func validateOutputFileName(field, name, expectedExt string) error {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("%s is required", field)
|
|
}
|
|
if filepath.Base(trimmed) != trimmed {
|
|
return fmt.Errorf("%s must be a file name, not a path: %q", field, name)
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(trimmed), expectedExt) {
|
|
return fmt.Errorf("%s must use %s extension: %q", field, expectedExt, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathIsUnder(root, rel string) bool {
|
|
root = strings.Trim(strings.TrimSpace(filepath.ToSlash(root)), "/")
|
|
rel = strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/")
|
|
if root == "" || rel == "" {
|
|
return false
|
|
}
|
|
return rel == root || strings.HasPrefix(rel, root+"/")
|
|
}
|
|
|
|
func validateRelativePath(field, path string) []error {
|
|
var failures []error
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return failures
|
|
}
|
|
if filepath.IsAbs(trimmed) {
|
|
failures = append(failures, fmt.Errorf("%s must be relative to the repository root: %q", field, path))
|
|
}
|
|
clean := filepath.Clean(filepath.FromSlash(trimmed))
|
|
if clean == "." {
|
|
return failures
|
|
}
|
|
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
failures = append(failures, fmt.Errorf("%s must not escape the repository root: %q", field, path))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTreeRootPath(field, path string) []error {
|
|
failures := validateRelativePath(field, path)
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return failures
|
|
}
|
|
if filepath.Clean(filepath.FromSlash(trimmed)) == "." {
|
|
failures = append(failures, fmt.Errorf("%s must not resolve to the repository root: %q", field, path))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func sameCleanPath(a, b string) bool {
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|