diff --git a/MODEL_COMPILATION_ON_BUILDTIME_CONTRACT.md b/MODEL_COMPILATION_ON_BUILDTIME_CONTRACT.md index a720dd7..2db9a43 100644 --- a/MODEL_COMPILATION_ON_BUILDTIME_CONTRACT.md +++ b/MODEL_COMPILATION_ON_BUILDTIME_CONTRACT.md @@ -423,7 +423,7 @@ Minimum messages: - `Preparing model compiler...` - `Preparing model compilation workspace...` - `Compiling model 12/324: part/belt/pfa0_belt018.mdl` -- `Reusing compiled model: vfxs/head_accessories/hfx_bandana.mdl` +- `Reusing compiled model: vfxs/headaccessory/hfx_bandana.mdl` - `Skipping binary model source: creatures/foo.mdl` - `FAILED model compile: placeables/broken_model.mdl` diff --git a/README.md b/README.md index 8da2371..27c431c 100644 --- a/README.md +++ b/README.md @@ -414,12 +414,19 @@ For `head_visualeffects` autogen, producers can publish the group-to-row policy in `autogen.producers[].head_visualeffects`, and consumers can override or extend it in `autogen.consumers[].head_visualeffects`. The toolkit uses compatibility defaults for older configs and manifests, but YAML can own the -group prefixes, model stem prefixes to strip, key/label formats, model column, -and default row values. Individual groups can override the model column with -`model_column` or write the discovered model stem to several columns with -`model_columns`, which is useful for root-node visualeffects. Supported format -tokens are `{dataset}`, `{group}`, `{prefix}`, `{stem}`, `{stem_upper}`, and -`{model_stem}`. +group token source, category source, delimiter, case policy, legacy group +mapping, model stem prefixes to strip, key/label formats, model column, and +default row values. `group_token_source: folder_name` uses the manifest folder +group directly instead of a prefix mapping, and `category_from: +immediate_parent` can expose the model's category folder in keys and labels. +`legacy_groups` maps released manifest group names to current folder groups, +and `legacy_key_format` preserves row IDs when migrating older generated lock +keys. Individual groups can override the model column with `model_column` or +write the discovered model stem to several columns with `model_columns`, which +is useful for root-node visualeffects. Supported format tokens are `{dataset}`, +`{group}`, `{group_raw}`, `{prefix}`, `{category}`, `{category_upper}`, +`{category_segment}`, `{category_segment_upper}`, `{subgroup}`, `{stem}`, +`{stem_upper}`, `{model_stem}`, and `{delimiter}`. For `parts_rows` generated assets, `parts_rows.acbonus` can normalize final ACBONUS values before module overrides are applied. The diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 2b6988c..ba32dbe 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2178,10 +2178,10 @@ func TestPlanHAKsWritesManifestWithoutArchives(t *testing.T) { func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) - mustMkdir(t, filepath.Join(root, "assets", "vfxs", "chest_accessories")) - mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_accessories")) - mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_decorations")) - mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair")) + mustMkdir(t, filepath.Join(root, "assets", "vfxs", "chestaccessory")) + mustMkdir(t, filepath.Join(root, "assets", "vfxs", "headaccessory")) + mustMkdir(t, filepath.Join(root, "assets", "vfxs", "headdecoration")) + mustMkdir(t, filepath.Join(root, "assets", "vfxs", "headfeature", "hair")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ @@ -2200,7 +2200,7 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) { "id": "head_visualeffects", "root": "vfxs", - "include": ["chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"], + "include": ["chestaccessory/**/*.mdl", "headaccessory/**/*.mdl", "headdecoration/**/*.mdl", "headfeature/**/*.mdl"], "derive": { "kind": "model_stem", "group_from": "first_path_segment" @@ -2212,7 +2212,7 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) }, "head_visualeffects": { "groups": { - "head_accessories": { + "headaccessory": { "prefix": "headaccessory", "model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"] } @@ -2233,10 +2233,10 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) } `) - mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "sash") - mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "bandana") - mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_decorations", "hfx_laurel.mdl"), "laurel") - mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "hair") + mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "chestaccessory", "tfx_sash.mdl"), "sash") + mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "headaccessory", "hfx_bandana.mdl"), "bandana") + mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "headdecoration", "hfx_laurel.mdl"), "laurel") + mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "headfeature", "hair", "hfx_hair_bangs.mdl"), "hair") p, err := project.Load(root) if err != nil { @@ -2267,16 +2267,16 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) text := string(headRaw) for _, want := range []string{ `"id": "head_visualeffects"`, - `"group": "chest_accessories"`, + `"group": "chestaccessory"`, `"model_stem": "tfx_sash"`, - `"group": "head_accessories"`, + `"group": "headaccessory"`, `"model_stem": "hfx_bandana"`, `"head_visualeffects": {`, `"model_columns": [`, `"Imp_Root_H_Node"`, - `"group": "head_decorations"`, + `"group": "headdecoration"`, `"model_stem": "hfx_laurel"`, - `"group": "head_features"`, + `"group": "headfeature"`, `"model_stem": "hfx_hair_bangs"`, } { if !strings.Contains(text, want) { @@ -2285,6 +2285,103 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) } } +func TestBuildHAKsWritesFolderCategoryInHeadVisualeffectsAutogenManifest(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src")) + mustMkdir(t, filepath.Join(root, "assets", "vfxs", "headfeature", "sinfar", "ears_plt")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod", + "hak_order": ["group:core"] + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + }, + "autogen": { + "producers": [ + { + "id": "head_visualeffects", + "root": "vfxs", + "include": ["headfeature/**/*.mdl"], + "derive": { + "kind": "model_stem", + "group_from": "first_path_segment" + }, + "manifest": { + "release_tag": "head-vfx-manifest-current", + "asset_name": "sow-head-vfx-manifest.json", + "cache_name": "sow-head-vfx-manifest.json" + }, + "head_visualeffects": { + "groups": { + "headfeature": { + "model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"] + } + }, + "group_token_source": "folder_name", + "category_from": "immediate_parent", + "delimiter": "/", + "case": "preserve", + "strip_model_prefixes": ["hfx_"], + "key_format": "{dataset}:{group}{delimiter}{category_segment}{stem}", + "label_format": "{group}{delimiter}{category_segment}{stem}" + } + } + ] + }, + "haks": [ + { + "name": "core", + "priority": 1, + "max_bytes": 0, + "split": false, + "include": ["vfxs/**"] + } + ] +} +`) + + mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "headfeature", "sinfar", "ears_plt", "hfx_Ear_EL_L.mdl"), "ear") + + 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) + } + + if _, err := BuildHAKs(p); err != nil { + t.Fatalf("build haks: %v", err) + } + headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-head-vfx-manifest.json")) + if err != nil { + t.Fatalf("read head visualeffects manifest: %v", err) + } + text := string(headRaw) + for _, want := range []string{ + `"group": "headfeature"`, + `"subgroup": "sinfar/ears_plt"`, + `"category": "ears_plt"`, + `"model_stem": "hfx_Ear_EL_L"`, + `"source": "headfeature/sinfar/ears_plt/hfx_Ear_EL_L.mdl"`, + `"group_token_source": "folder_name"`, + `"category_from": "immediate_parent"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("expected head visualeffects manifest to contain %q, got:\n%s", want, text) + } + } +} + func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) diff --git a/internal/project/project.go b/internal/project/project.go index a7f96ab..7343324 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -388,9 +388,15 @@ type AutogenConsumerConfig struct { type HeadVisualeffectsConfig struct { Groups map[string]HeadVisualeffectGroupConfig `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"` + LegacyGroups map[string]string `json:"legacy_groups,omitempty" yaml:"legacy_groups"` + LegacyKeyFormat string `json:"legacy_key_format,omitempty" yaml:"legacy_key_format"` ModelColumn string `json:"model_column,omitempty" yaml:"model_column"` RowDefaults map[string]string `json:"row_defaults,omitempty" yaml:"row_defaults"` } @@ -1807,15 +1813,45 @@ func validateGeneratedConfig(cfg GeneratedConfig) []error { func headVisualeffectsConfigConfigured(cfg HeadVisualeffectsConfig) 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) != "" || + len(cfg.LegacyGroups) > 0 || + strings.TrimSpace(cfg.LegacyKeyFormat) != "" || strings.TrimSpace(cfg.ModelColumn) != "" || len(cfg.RowDefaults) > 0 } func validateHeadVisualeffectsConfig(fieldPrefix string, cfg HeadVisualeffectsConfig) []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 == "" { @@ -1824,7 +1860,7 @@ func validateHeadVisualeffectsConfig(fieldPrefix string, cfg HeadVisualeffectsCo } failures = append(failures, validateTreeRootPath(fieldPrefix+".groups["+group+"]", group)...) prefix := strings.TrimSpace(groupCfg.Prefix) - if 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") { @@ -1852,6 +1888,21 @@ func validateHeadVisualeffectsConfig(fieldPrefix string, cfg HeadVisualeffectsCo } } } + for legacyGroup, group := range cfg.LegacyGroups { + legacyGroup = strings.TrimSpace(legacyGroup) + group = strings.TrimSpace(group) + if legacyGroup == "" { + failures = append(failures, fmt.Errorf("%s.legacy_groups contains an empty legacy group key", fieldPrefix)) + continue + } + if group == "" { + failures = append(failures, fmt.Errorf("%s.legacy_groups[%s] must not be empty", fieldPrefix, legacyGroup)) + } + failures = append(failures, validateTreeRootPath(fieldPrefix+".legacy_groups["+legacyGroup+"]", legacyGroup)...) + if group != "" { + failures = append(failures, validateTreeRootPath(fieldPrefix+".legacy_groups["+legacyGroup+"]", group)...) + } + } 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)) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index e0dec09..74e3ad2 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1332,7 +1332,7 @@ func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) { Dataset: "visualeffects", Mode: "unsupported", Root: "vfxs", - Include: []string{"head_accessories/**/*.mdl"}, + Include: []string{"headaccessory/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, Manifest: AutogenManifestConfig{ ReleaseTag: "head-vfx-manifest-current", @@ -1416,6 +1416,66 @@ func TestValidateLayoutRejectsInvalidHeadVisualeffectsConfig(t *testing.T) { } } +func TestValidateLayoutRejectsInvalidHeadVisualeffectsNamingConfig(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "src")) + mkdirAll(t, filepath.Join(root, "build")) + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Source: "src", Build: "build"}, + Autogen: AutogenConfig{ + Consumers: []AutogenConsumerConfig{ + { + ID: "head_visualeffects", + Producer: "head_visualeffects", + Dataset: "visualeffects", + Mode: "head_visualeffects", + Root: "vfxs", + Include: []string{"headfeature/**/*.mdl"}, + Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, + Manifest: AutogenManifestConfig{ + ReleaseTag: "head-vfx-manifest-current", + AssetName: "sow-head-vfx-manifest.json", + CacheName: "sow-head-vfx-manifest.json", + }, + HeadVisualeffects: HeadVisualeffectsConfig{ + Groups: map[string]HeadVisualeffectGroupConfig{ + "headfeature": {}, + }, + GroupTokenSource: "unsupported", + CategoryFrom: "unsupported", + Delimiter: " ", + Case: "unsupported", + LegacyGroups: map[string]string{ + "head_features": "", + }, + }, + }, + }, + }, + }, + } + + err := proj.ValidateLayout() + if err == nil { + t.Fatal("expected head visualeffects naming validation error") + } + for _, needle := range []string{ + "autogen.consumers[0].head_visualeffects.group_token_source", + "autogen.consumers[0].head_visualeffects.category_from", + "autogen.consumers[0].head_visualeffects.delimiter", + "autogen.consumers[0].head_visualeffects.case", + "autogen.consumers[0].head_visualeffects.legacy_groups[head_features]", + } { + if !strings.Contains(err.Error(), needle) { + t.Fatalf("expected validation error to mention %s, got %v", needle, err) + } + } +} + func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets")) @@ -1460,7 +1520,7 @@ func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) { ID: "cachedmodels", Mode: "cachedmodels_rows", Root: "vfxs", - Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, + Include: []string{"headaccessory/**/*.mdl", "headfeature/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, }, }, diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index f666277..93bb549 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -29,6 +29,7 @@ type autogenManifestEntry struct { Source string `json:"source"` Group string `json:"group,omitempty"` Subgroup string `json:"subgroup,omitempty"` + Category string `json:"category,omitempty"` ModelStem string `json:"model_stem,omitempty"` RowID int `json:"row_id,omitempty"` } @@ -319,6 +320,7 @@ func deriveAutogenManifestEntry(rel string, derive project.AutogenDeriveConfig) entry.Group = parts[0] if len(parts) > 2 { entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/") + entry.Category = parts[len(parts)-2] } } @@ -740,10 +742,25 @@ func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDatase rowID, ok := lockData[key] if !ok { + var migratedLegacyKey string if preservedRowID, preserved := historicalLockData[key]; preserved { rowID = preservedRowID } else { - rowID = allocateNextID() + preservedLegacyID := false + for _, legacyKey := range headVisualeffectLegacyKeys(dataset.Dataset.Name, entry, policy) { + if preservedRowID, preserved := historicalLockData[legacyKey]; preserved { + rowID = preservedRowID + preservedLegacyID = true + migratedLegacyKey = legacyKey + break + } + } + if !preservedLegacyID { + rowID = allocateNextID() + } + } + if migratedLegacyKey != "" && migratedLegacyKey != key { + delete(lockData, migratedLegacyKey) } lockData[key] = rowID } @@ -772,9 +789,15 @@ func nextAvailableAutogenID(used map[int]struct{}) int { type headVisualeffectsPolicy struct { Groups map[string]headVisualeffectsGroupPolicy + GroupTokenSource string + CategoryFrom string + Delimiter string + Case string StripModelPrefixes []string KeyFormat string LabelFormat string + LegacyGroups map[string]string + LegacyKeyFormat string ModelColumn string RowDefaults map[string]string } @@ -798,11 +821,13 @@ func applyHeadVisualeffectsConfig(policy *headVisualeffectsPolicy, cfg project.H for group, groupCfg := range cfg.Groups { group = strings.TrimSpace(group) prefix := strings.TrimSpace(groupCfg.Prefix) - if group == "" || prefix == "" { + if group == "" { continue } groupPolicy := policy.Groups[group] - groupPolicy.Prefix = prefix + if prefix != "" { + groupPolicy.Prefix = prefix + } if columns := normalizeHeadVisualeffectsModelColumns(groupCfg.ModelColumn, groupCfg.ModelColumns); len(columns) > 0 { groupPolicy.ModelColumns = columns } @@ -812,6 +837,18 @@ func applyHeadVisualeffectsConfig(policy *headVisualeffectsPolicy, cfg project.H mergeStringMap(groupPolicy.RowDefaults, normalizeHeadVisualeffectsRowDefaults(groupCfg.RowDefaults)) policy.Groups[group] = groupPolicy } + if strings.TrimSpace(cfg.GroupTokenSource) != "" { + policy.GroupTokenSource = strings.TrimSpace(cfg.GroupTokenSource) + } + if strings.TrimSpace(cfg.CategoryFrom) != "" { + policy.CategoryFrom = strings.TrimSpace(cfg.CategoryFrom) + } + if cfg.Delimiter != "" { + policy.Delimiter = cfg.Delimiter + } + if strings.TrimSpace(cfg.Case) != "" { + policy.Case = strings.TrimSpace(cfg.Case) + } if len(cfg.StripModelPrefixes) > 0 { policy.StripModelPrefixes = append([]string(nil), cfg.StripModelPrefixes...) } @@ -821,6 +858,22 @@ func applyHeadVisualeffectsConfig(policy *headVisualeffectsPolicy, cfg project.H if strings.TrimSpace(cfg.LabelFormat) != "" { policy.LabelFormat = strings.TrimSpace(cfg.LabelFormat) } + if len(cfg.LegacyGroups) > 0 { + if policy.LegacyGroups == nil { + policy.LegacyGroups = map[string]string{} + } + for legacyGroup, group := range cfg.LegacyGroups { + legacyGroup = strings.TrimSpace(legacyGroup) + group = strings.TrimSpace(group) + if legacyGroup == "" || group == "" { + continue + } + policy.LegacyGroups[legacyGroup] = group + } + } + if strings.TrimSpace(cfg.LegacyKeyFormat) != "" { + policy.LegacyKeyFormat = strings.TrimSpace(cfg.LegacyKeyFormat) + } if strings.TrimSpace(cfg.ModelColumn) != "" { policy.ModelColumn = strings.TrimSpace(cfg.ModelColumn) } @@ -834,6 +887,10 @@ func defaultHeadVisualeffectsPolicy() headVisualeffectsPolicy { "head_decorations": {Prefix: "headdecoration"}, "head_features": {Prefix: "headfeature"}, }, + GroupTokenSource: "prefix", + CategoryFrom: "none", + Delimiter: "_", + Case: "preserve", StripModelPrefixes: []string{"hfx_", "vfx_"}, KeyFormat: "{dataset}:{prefix}_{stem}", LabelFormat: "{prefix}_{stem_upper}", @@ -882,11 +939,104 @@ func mergeStringMap(target map[string]string, source map[string]string) { } func headVisualeffectIdentity(dataset string, entry autogenManifestEntry, policy headVisualeffectsPolicy) (string, string, string, headVisualeffectsGroupPolicy, bool) { + entry = normalizeHeadVisualeffectEntry(entry, policy) group, ok := policy.Groups[entry.Group] if !ok || strings.TrimSpace(entry.ModelStem) == "" { return "", "", "", headVisualeffectsGroupPolicy{}, false } modelStem := strings.TrimSpace(entry.ModelStem) + stem := stripHeadVisualeffectModelPrefix(modelStem, policy) + stem = strings.TrimSpace(stem) + if stem == "" { + return "", "", "", headVisualeffectsGroupPolicy{}, false + } + category := headVisualeffectCategory(entry, policy) + groupToken := headVisualeffectGroupToken(entry.Group, group, policy) + values := map[string]string{ + "dataset": dataset, + "group": applyHeadVisualeffectCase(groupToken, policy), + "group_raw": entry.Group, + "prefix": applyHeadVisualeffectCase(group.Prefix, policy), + "category": applyHeadVisualeffectCase(category, policy), + "category_upper": strings.ToUpper(category), + "category_segment": headVisualeffectDelimitedSegment(applyHeadVisualeffectCase(category, policy), policy.Delimiter), + "category_segment_upper": headVisualeffectDelimitedSegment(strings.ToUpper(category), policy.Delimiter), + "subgroup": applyHeadVisualeffectCase(entry.Subgroup, policy), + "stem": applyHeadVisualeffectCase(stem, policy), + "stem_upper": strings.ToUpper(stem), + "model_stem": modelStem, + "delimiter": policy.Delimiter, + } + key := expandHeadVisualeffectsFormat(policy.KeyFormat, values) + label := expandHeadVisualeffectsFormat(policy.LabelFormat, values) + if strings.TrimSpace(key) == "" || strings.TrimSpace(label) == "" { + return "", "", "", headVisualeffectsGroupPolicy{}, false + } + return key, label, modelStem, group, true +} + +func headVisualeffectLegacyKeys(dataset string, entry autogenManifestEntry, policy headVisualeffectsPolicy) []string { + if strings.TrimSpace(policy.LegacyKeyFormat) == "" { + return nil + } + entry = normalizeHeadVisualeffectEntry(entry, policy) + group, ok := policy.Groups[entry.Group] + if !ok || strings.TrimSpace(entry.ModelStem) == "" { + return nil + } + modelStem := strings.TrimSpace(entry.ModelStem) + stem := strings.TrimSpace(stripHeadVisualeffectModelPrefix(modelStem, policy)) + if stem == "" { + return nil + } + groupToken := headVisualeffectGroupToken(entry.Group, group, policy) + values := map[string]string{ + "dataset": dataset, + "group": applyHeadVisualeffectCase(groupToken, policy), + "group_raw": entry.Group, + "prefix": applyHeadVisualeffectCase(group.Prefix, policy), + "stem": applyHeadVisualeffectCase(stem, policy), + "stem_upper": strings.ToUpper(stem), + "model_stem": modelStem, + "delimiter": policy.Delimiter, + } + legacyKey := strings.TrimSpace(expandHeadVisualeffectsFormat(policy.LegacyKeyFormat, values)) + if legacyKey == "" { + return nil + } + return []string{legacyKey} +} + +func normalizeHeadVisualeffectEntry(entry autogenManifestEntry, policy headVisualeffectsPolicy) autogenManifestEntry { + entry.Group = strings.TrimSpace(entry.Group) + if mapped, ok := policy.LegacyGroups[entry.Group]; ok && strings.TrimSpace(mapped) != "" { + entry.Group = strings.TrimSpace(mapped) + } + if strings.TrimSpace(entry.Group) == "" && strings.TrimSpace(entry.Source) != "" { + parts := strings.Split(filepath.ToSlash(entry.Source), "/") + if len(parts) > 1 { + entry.Group = parts[0] + if mapped, ok := policy.LegacyGroups[entry.Group]; ok && strings.TrimSpace(mapped) != "" { + entry.Group = strings.TrimSpace(mapped) + } + } + } + if strings.TrimSpace(entry.Subgroup) == "" && strings.TrimSpace(entry.Source) != "" { + parts := strings.Split(filepath.ToSlash(entry.Source), "/") + if len(parts) > 2 { + entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/") + } + } + if strings.TrimSpace(entry.Category) == "" && strings.TrimSpace(entry.Source) != "" { + parts := strings.Split(filepath.ToSlash(entry.Source), "/") + if len(parts) > 2 { + entry.Category = parts[len(parts)-2] + } + } + return entry +} + +func stripHeadVisualeffectModelPrefix(modelStem string, policy headVisualeffectsPolicy) string { stem := modelStem for _, prefix := range policy.StripModelPrefixes { prefix = strings.TrimSpace(prefix) @@ -895,24 +1045,46 @@ func headVisualeffectIdentity(dataset string, entry autogenManifestEntry, policy } stem = strings.TrimPrefix(stem, prefix) } - stem = strings.TrimSpace(stem) - if stem == "" { - return "", "", "", headVisualeffectsGroupPolicy{}, false + return stem +} + +func headVisualeffectGroupToken(groupName string, group headVisualeffectsGroupPolicy, policy headVisualeffectsPolicy) string { + if policy.GroupTokenSource == "folder_name" { + return groupName } - values := map[string]string{ - "dataset": dataset, - "group": entry.Group, - "prefix": group.Prefix, - "stem": stem, - "stem_upper": strings.ToUpper(stem), - "model_stem": modelStem, + if strings.TrimSpace(group.Prefix) != "" { + return group.Prefix } - key := expandHeadVisualeffectsFormat(policy.KeyFormat, values) - label := expandHeadVisualeffectsFormat(policy.LabelFormat, values) - if strings.TrimSpace(key) == "" || strings.TrimSpace(label) == "" { - return "", "", "", headVisualeffectsGroupPolicy{}, false + return groupName +} + +func headVisualeffectCategory(entry autogenManifestEntry, policy headVisualeffectsPolicy) string { + switch policy.CategoryFrom { + case "immediate_parent": + return strings.TrimSpace(entry.Category) + case "full_subgroup": + return strings.TrimSpace(entry.Subgroup) + default: + return "" + } +} + +func headVisualeffectDelimitedSegment(value, delimiter string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return value + delimiter +} + +func applyHeadVisualeffectCase(value string, policy headVisualeffectsPolicy) string { + switch policy.Case { + case "lower": + return strings.ToLower(value) + case "upper": + return strings.ToUpper(value) + default: + return value } - return key, label, modelStem, group, true } func expandHeadVisualeffectsFormat(format string, values map[string]string) string { @@ -997,9 +1169,15 @@ func formatAutogenManifest(root string, producer project.AutogenProducerConfig, func autogenHeadVisualeffectsConfigConfigured(cfg project.HeadVisualeffectsConfig) bool { return len(cfg.Groups) > 0 || + strings.TrimSpace(cfg.GroupTokenSource) != "" || + strings.TrimSpace(cfg.CategoryFrom) != "" || + cfg.Delimiter != "" || + strings.TrimSpace(cfg.Case) != "" || len(cfg.StripModelPrefixes) > 0 || strings.TrimSpace(cfg.KeyFormat) != "" || strings.TrimSpace(cfg.LabelFormat) != "" || + len(cfg.LegacyGroups) > 0 || + strings.TrimSpace(cfg.LegacyKeyFormat) != "" || strings.TrimSpace(cfg.ModelColumn) != "" || len(cfg.RowDefaults) > 0 } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index bc8d03a..c3e8311 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -7409,12 +7409,150 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test } } +func TestApplyAutogenConsumersUsesFolderDrivenSlashHeadVisualeffectsNames(t *testing.T) { + root := testProjectRoot(t) + overrideRoot := filepath.Join(root, "autogen-assets") + mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "headfeature", "sinfar", "ears_plt")) + writeFile(t, filepath.Join(overrideRoot, "vfxs", "headfeature", "sinfar", "ears_plt", "hfx_Ear_EL_L.mdl"), "mdl\n") + + p := testProject(root) + p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{ + { + ID: "head_visualeffects", + Producer: "head_visualeffects", + Dataset: "visualeffects", + Mode: "head_visualeffects", + Root: "vfxs", + Include: []string{"headfeature/**/*.mdl"}, + Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, + Manifest: project.AutogenManifestConfig{ReleaseTag: "head-vfx-manifest-current", AssetName: "sow-head-vfx-manifest.json", CacheName: "sow-head-vfx-manifest.json"}, + LocalOverrideRoot: "autogen-assets", + HeadVisualeffects: project.HeadVisualeffectsConfig{ + Groups: map[string]project.HeadVisualeffectGroupConfig{ + "headfeature": { + ModelColumns: []string{"Imp_HeadCon_Node", "Imp_Root_H_Node"}, + }, + }, + GroupTokenSource: "folder_name", + CategoryFrom: "immediate_parent", + Delimiter: "/", + Case: "preserve", + StripModelPrefixes: []string{"hfx_"}, + KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}", + LabelFormat: "{group}{delimiter}{category_segment}{stem}", + LegacyKeyFormat: "{dataset}:{group}_{stem}", + RowDefaults: map[string]string{ + "Type_FD": "D", + "OrientWithGround": "0", + "OrientWithObject": "1", + }, + }, + }, + } + + collected := []nativeCollectedDataset{ + { + Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase}, + Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_H_Node", "OrientWithObject"}, + Rows: nil, + LockData: map[string]int{ + "visualeffects:headfeature_Ear_EL_L": 11030, + }, + }, + } + + got, err := applyAutogenConsumers(p, collected, nil) + if err != nil { + t.Fatalf("applyAutogenConsumers failed: %v", err) + } + if len(got) != 1 || len(got[0].Rows) != 1 { + t.Fatalf("expected one autogenerated visualeffects row, got %#v", got) + } + row := got[0].Rows[0] + if row["key"] != "visualeffects:headfeature/ears_plt/Ear_EL_L" { + t.Fatalf("expected folder-driven key, got %#v", row) + } + if row["id"] != 11030 { + t.Fatalf("expected legacy lock id to be preserved, got %#v", row) + } + if row["Label"] != "headfeature/ears_plt/Ear_EL_L" { + t.Fatalf("expected preserve-case slash label, got %#v", row) + } + if row["Imp_HeadCon_Node"] != "hfx_Ear_EL_L" || row["Imp_Root_H_Node"] != "hfx_Ear_EL_L" { + t.Fatalf("expected configured model columns, got %#v", row) + } +} + +func TestHeadVisualeffectsConsumerMapsLegacyManifestGroupsToFolderDrivenNames(t *testing.T) { + collected := []nativeCollectedDataset{ + { + Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase}, + Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_H_Node", "OrientWithObject"}, + Rows: nil, + LockData: map[string]int{ + "visualeffects:headfeature_ear_el_l": 11030, + }, + }, + } + entries := []autogenManifestEntry{ + { + Group: "head_features", + Source: "head_features/sinfar/ears_plt/hfx_ear_el_l.mdl", + ModelStem: "hfx_ear_el_l", + }, + } + consumer := project.AutogenConsumerConfig{ + Dataset: "visualeffects", + HeadVisualeffects: project.HeadVisualeffectsConfig{ + Groups: map[string]project.HeadVisualeffectGroupConfig{ + "headfeature": { + ModelColumns: []string{"Imp_HeadCon_Node", "Imp_Root_H_Node"}, + }, + }, + GroupTokenSource: "folder_name", + CategoryFrom: "immediate_parent", + Delimiter: "/", + Case: "preserve", + StripModelPrefixes: []string{ + "hfx_", + }, + KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}", + LabelFormat: "{group}{delimiter}{category_segment}{stem}", + LegacyGroups: map[string]string{"head_features": "headfeature"}, + LegacyKeyFormat: "{dataset}:{group}_{stem}", + RowDefaults: map[string]string{ + "Type_FD": "D", + "OrientWithGround": "0", + "OrientWithObject": "1", + }, + }, + } + + got, err := augmentWithAutogeneratedHeadVisualeffects(collected, entries, consumer, nil) + if err != nil { + t.Fatalf("augmentWithAutogeneratedHeadVisualeffects failed: %v", err) + } + if len(got) != 1 || len(got[0].Rows) != 1 { + t.Fatalf("expected one autogenerated row, got %#v", got) + } + row := got[0].Rows[0] + if row["key"] != "visualeffects:headfeature/ears_plt/ear_el_l" || row["id"] != 11030 { + t.Fatalf("expected migrated slash key with preserved ID, got %#v", row) + } + if _, exists := got[0].LockData["visualeffects:headfeature/ears_plt/ear_el_l"]; !exists { + t.Fatalf("expected new slash key in lock data, got %#v", got[0].LockData) + } + if _, exists := got[0].LockData["visualeffects:headfeature_ear_el_l"]; exists { + t.Fatalf("expected legacy key to be removed from active lock data, got %#v", got[0].LockData) + } +} + func TestBuildNativeUsesConfiguredHeadVisualeffectsGroups(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) mkdirAll(t, filepath.Join(root, "topdata", "data", "visualeffects")) - mkdirAll(t, filepath.Join(root, "autogen-assets", "vfxs", "chest_accessories")) + mkdirAll(t, filepath.Join(root, "autogen-assets", "vfxs", "chestaccessory")) mkdirAll(t, filepath.Join(root, "autogen-assets", "vfxs", "head_jewels")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "base.json"), `{ @@ -7422,7 +7560,7 @@ func TestBuildNativeUsesConfiguredHeadVisualeffectsGroups(t *testing.T) { "columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "OrientWithObject"], "rows": [] }`+"\n") - writeFile(t, filepath.Join(root, "autogen-assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "autogen-assets", "vfxs", "chestaccessory", "tfx_sash.mdl"), "mdl\n") writeFile(t, filepath.Join(root, "autogen-assets", "vfxs", "head_jewels", "hfx_circlet.mdl"), "mdl\n") writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: @@ -7442,7 +7580,7 @@ autogen: mode: head_visualeffects root: vfxs include: - - chest_accessories/**/*.mdl + - chestaccessory/**/*.mdl - head_jewels/**/*.mdl derive: kind: model_stem @@ -7454,7 +7592,7 @@ autogen: local_override_root: autogen-assets head_visualeffects: groups: - chest_accessories: + chestaccessory: prefix: chestaccessory model_columns: - Imp_Root_S_Node @@ -7499,16 +7637,16 @@ autogen: func TestBuildGenerated2DAAssetsAppendsCachedModelsFromHeadVisualeffects(t *testing.T) { root := testProjectRoot(t) - mkdirAll(t, filepath.Join(root, "assets", "vfxs", "chest_accessories")) - mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_accessories")) - mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_decorations")) - mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair")) + mkdirAll(t, filepath.Join(root, "assets", "vfxs", "chestaccessory")) + mkdirAll(t, filepath.Join(root, "assets", "vfxs", "headaccessory")) + mkdirAll(t, filepath.Join(root, "assets", "vfxs", "headdecoration")) + mkdirAll(t, filepath.Join(root, "assets", "vfxs", "headfeature", "hair")) mkdirAll(t, filepath.Join(root, "topdata", "data", "cachedmodels")) - writeFile(t, filepath.Join(root, "assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "mdl\n") - writeFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "mdl\n") - writeFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_existing.mdl"), "mdl\n") - writeFile(t, filepath.Join(root, "assets", "vfxs", "head_decorations", "hfx_laurel.mdl"), "mdl\n") - writeFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "assets", "vfxs", "chestaccessory", "tfx_sash.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "assets", "vfxs", "headaccessory", "hfx_bandana.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "assets", "vfxs", "headaccessory", "hfx_existing.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "assets", "vfxs", "headdecoration", "hfx_laurel.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "assets", "vfxs", "headfeature", "hair", "hfx_hair_bangs.mdl"), "mdl\n") writeFile(t, filepath.Join(root, "topdata", "data", "cachedmodels", "base.json"), `{ "columns": ["Model"], "rows": [ @@ -7530,7 +7668,7 @@ func TestBuildGenerated2DAAssetsAppendsCachedModelsFromHeadVisualeffects(t *test ID: "cachedmodels", Mode: "cachedmodels_rows", Root: "vfxs", - Include: []string{"chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"}, + Include: []string{"chestaccessory/**/*.mdl", "headaccessory/**/*.mdl", "headdecoration/**/*.mdl", "headfeature/**/*.mdl"}, Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, }, },