VFX Category Label Generation

This commit is contained in:
2026-05-31 13:47:14 +02:00
parent 67c03fa6bd
commit aa47532ef1
7 changed files with 587 additions and 56 deletions
+111 -14
View File
@@ -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"))
+52 -1
View File
@@ -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))
+62 -2
View File
@@ -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"},
},
},
+196 -18
View File
@@ -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
}
+152 -14
View File
@@ -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"},
},
},