diff --git a/README.md b/README.md index 1dae028..7359d00 100644 --- a/README.md +++ b/README.md @@ -401,8 +401,19 @@ and `package_root` plus the repository's HAK include patterns determine where the resources are packaged. For `cachedmodels_rows` generated assets, the configured autogen scan appends -discovered model stems to the selected `cachedmodels` dataset without `.mdl` -extensions and skips models already present in authored rows. +discovered model stems from the configured include globs to the selected +`cachedmodels` dataset without `.mdl` extensions and skips models already +present in authored rows. + +For `head_visualeffects` autogen consumers, repositories can configure the +group-to-row policy in `autogen.consumers[].head_visualeffects`. The toolkit +uses compatibility defaults for older configs, 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}`. 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 92c02ee..1ff71ec 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2178,7 +2178,9 @@ 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, "build")) @@ -2198,7 +2200,7 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) { "id": "head_visualeffects", "root": "vfxs", - "include": ["head_accessories/**/*.mdl", "head_features/**/*.mdl"], + "include": ["chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"], "derive": { "kind": "model_stem", "group_from": "first_path_segment" @@ -2223,7 +2225,9 @@ 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") p, err := project.Load(root) @@ -2255,8 +2259,12 @@ func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) text := string(headRaw) for _, want := range []string{ `"id": "head_visualeffects"`, + `"group": "chest_accessories"`, + `"model_stem": "tfx_sash"`, `"group": "head_accessories"`, `"model_stem": "hfx_bandana"`, + `"group": "head_decorations"`, + `"model_stem": "hfx_laurel"`, `"group": "head_features"`, `"model_stem": "hfx_hair_bangs"`, } { diff --git a/internal/project/project.go b/internal/project/project.go index 12293e5..d4c949b 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -371,17 +371,34 @@ type AutogenProducerConfig struct { } 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"` - Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"` - LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"` + 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"` + HeadVisualeffects HeadVisualeffectsConfig `json:"head_visualeffects" yaml:"head_visualeffects"` + Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"` + LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"` +} + +type HeadVisualeffectsConfig struct { + Groups map[string]HeadVisualeffectGroupConfig `json:"groups" yaml:"groups"` + StripModelPrefixes []string `json:"strip_model_prefixes" yaml:"strip_model_prefixes"` + KeyFormat string `json:"key_format" yaml:"key_format"` + LabelFormat string `json:"label_format" yaml:"label_format"` + ModelColumn string `json:"model_column" yaml:"model_column"` + RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"` +} + +type HeadVisualeffectGroupConfig struct { + Prefix string `json:"prefix" yaml:"prefix"` + ModelColumn string `json:"model_column" yaml:"model_column"` + ModelColumns []string `json:"model_columns" yaml:"model_columns"` + RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"` } type PartsRowsConfig struct { @@ -1721,6 +1738,9 @@ func validateAutogenConfig(cfg AutogenConfig) []error { } failures = append(failures, validateGlobList(fieldPrefix+".include", consumer.Include)...) failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", consumer.Derive)...) + if strings.TrimSpace(consumer.Mode) == "head_visualeffects" && headVisualeffectsConfigConfigured(consumer.HeadVisualeffects) { + failures = append(failures, validateHeadVisualeffectsConfig(fieldPrefix+".head_visualeffects", consumer.HeadVisualeffects)...) + } failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", consumer.Manifest)...) } @@ -1781,6 +1801,76 @@ func validateGeneratedConfig(cfg GeneratedConfig) []error { return failures } +func headVisualeffectsConfigConfigured(cfg HeadVisualeffectsConfig) bool { + return len(cfg.Groups) > 0 || + len(cfg.StripModelPrefixes) > 0 || + strings.TrimSpace(cfg.KeyFormat) != "" || + strings.TrimSpace(cfg.LabelFormat) != "" || + strings.TrimSpace(cfg.ModelColumn) != "" || + len(cfg.RowDefaults) > 0 +} + +func validateHeadVisualeffectsConfig(fieldPrefix string, cfg HeadVisualeffectsConfig) []error { + var failures []error + 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 == "" { + 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 diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 96ba382..e0dec09 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1360,6 +1360,62 @@ func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) { } } +func TestValidateLayoutRejectsInvalidHeadVisualeffectsConfig(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{"head_jewels/**/*.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{ + "head_jewels": { + ModelColumns: []string{"Imp Root M Node"}, + }, + }, + StripModelPrefixes: []string{""}, + ModelColumn: "Imp HeadCon Node", + }, + }, + }, + }, + }, + } + + err := proj.ValidateLayout() + if err == nil { + t.Fatal("expected head visualeffects validation error") + } + for _, needle := range []string{ + "autogen.consumers[0].head_visualeffects.groups[head_jewels].prefix", + "autogen.consumers[0].head_visualeffects.groups[head_jewels].model_columns[0]", + "autogen.consumers[0].head_visualeffects.strip_model_prefixes[0]", + "autogen.consumers[0].head_visualeffects.model_column", + } { + 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")) diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index 2b26f60..f465057 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -74,7 +74,7 @@ func applyAutogenConsumers(p *project.Project, collected []nativeCollectedDatase case "parts_rows": result = augmentWithAutogeneratedParts(result, autogenPartsInventory(entries)) case "head_visualeffects": - result, err = augmentWithAutogeneratedHeadVisualeffects(result, entries) + result, err = augmentWithAutogeneratedHeadVisualeffects(result, entries, consumer) if err != nil { return nil, err } @@ -660,11 +660,12 @@ func augmentWithAutogeneratedCachedModels(collected []nativeCollectedDataset, en return result, nil } -func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDataset, entries []autogenManifestEntry) ([]nativeCollectedDataset, error) { +func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDataset, entries []autogenManifestEntry, consumer project.AutogenConsumerConfig) ([]nativeCollectedDataset, error) { if len(entries) == 0 { return collected, nil } + policy := resolveHeadVisualeffectsPolicy(consumer) result := append([]nativeCollectedDataset(nil), collected...) for i, dataset := range result { if dataset.Dataset.Name != "visualeffects" { @@ -714,12 +715,12 @@ func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDatase } for _, entry := range entries { - key, label, ok := headVisualeffectIdentity(entry) + key, label, modelStem, groupPolicy, ok := headVisualeffectIdentity(dataset.Dataset.Name, entry, policy) if !ok { continue } if existing, exists := rowByKey[key]; exists { - applyDiscoveredHeadVisualeffectDefaults(existing, dataset.Columns, entry.ModelStem, label) + applyDiscoveredHeadVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy) continue } @@ -732,7 +733,7 @@ func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDatase } lockData[key] = rowID } - newRow := createDefaultHeadVisualeffectRow(dataset.Columns, rowID, key, label, entry.ModelStem) + newRow := createDefaultHeadVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy) rows = append(rows, newRow) rowByKey[key] = newRow } @@ -755,25 +756,150 @@ func nextAvailableAutogenID(used map[int]struct{}) int { } } -func headVisualeffectIdentity(entry autogenManifestEntry) (string, string, bool) { - groupPrefix := map[string]string{ - "head_accessories": "headaccessory", - "head_features": "headfeature", - } - prefix, ok := groupPrefix[entry.Group] - if !ok || strings.TrimSpace(entry.ModelStem) == "" { - return "", "", false - } - stem := strings.TrimPrefix(entry.ModelStem, "hfx_") - stem = strings.TrimPrefix(stem, "vfx_") - stem = strings.TrimSpace(stem) - if stem == "" { - return "", "", false - } - return "visualeffects:" + prefix + "_" + stem, prefix + "_" + strings.ToUpper(stem), true +type headVisualeffectsPolicy struct { + Groups map[string]headVisualeffectsGroupPolicy + StripModelPrefixes []string + KeyFormat string + LabelFormat string + ModelColumn string + RowDefaults map[string]string } -func createDefaultHeadVisualeffectRow(columns []string, rowID int, key, label, modelStem string) map[string]any { +type headVisualeffectsGroupPolicy struct { + Prefix string + ModelColumns []string + RowDefaults map[string]string +} + +func resolveHeadVisualeffectsPolicy(consumer project.AutogenConsumerConfig) headVisualeffectsPolicy { + policy := defaultHeadVisualeffectsPolicy() + cfg := consumer.HeadVisualeffects + for group, groupCfg := range cfg.Groups { + group = strings.TrimSpace(group) + prefix := strings.TrimSpace(groupCfg.Prefix) + if group == "" || prefix == "" { + continue + } + groupPolicy := headVisualeffectsGroupPolicy{ + Prefix: prefix, + ModelColumns: normalizeHeadVisualeffectsModelColumns(groupCfg.ModelColumn, groupCfg.ModelColumns), + RowDefaults: normalizeHeadVisualeffectsRowDefaults(groupCfg.RowDefaults), + } + policy.Groups[group] = groupPolicy + } + if len(cfg.StripModelPrefixes) > 0 { + policy.StripModelPrefixes = append([]string(nil), cfg.StripModelPrefixes...) + } + if strings.TrimSpace(cfg.KeyFormat) != "" { + policy.KeyFormat = strings.TrimSpace(cfg.KeyFormat) + } + if strings.TrimSpace(cfg.LabelFormat) != "" { + policy.LabelFormat = strings.TrimSpace(cfg.LabelFormat) + } + if strings.TrimSpace(cfg.ModelColumn) != "" { + policy.ModelColumn = strings.TrimSpace(cfg.ModelColumn) + } + mergeStringMap(policy.RowDefaults, normalizeHeadVisualeffectsRowDefaults(cfg.RowDefaults)) + return policy +} + +func defaultHeadVisualeffectsPolicy() headVisualeffectsPolicy { + return headVisualeffectsPolicy{ + Groups: map[string]headVisualeffectsGroupPolicy{ + "head_accessories": {Prefix: "headaccessory"}, + "head_decorations": {Prefix: "headdecoration"}, + "head_features": {Prefix: "headfeature"}, + }, + StripModelPrefixes: []string{"hfx_", "vfx_"}, + KeyFormat: "{dataset}:{prefix}_{stem}", + LabelFormat: "{prefix}_{stem_upper}", + ModelColumn: "Imp_HeadCon_Node", + RowDefaults: map[string]string{ + "Type_FD": "D", + "OrientWithGround": "0", + "OrientWithObject": "1", + }, + } +} + +func normalizeHeadVisualeffectsModelColumns(modelColumn string, modelColumns []string) []string { + var result []string + if column := strings.TrimSpace(modelColumn); column != "" { + result = append(result, column) + } + for _, column := range modelColumns { + column = strings.TrimSpace(column) + if column == "" { + continue + } + if !slices.Contains(result, column) { + result = append(result, column) + } + } + return result +} + +func normalizeHeadVisualeffectsRowDefaults(defaults map[string]string) map[string]string { + result := map[string]string{} + for column, value := range defaults { + column = strings.TrimSpace(column) + if column == "" { + continue + } + result[column] = value + } + return result +} + +func mergeStringMap(target map[string]string, source map[string]string) { + for key, value := range source { + target[key] = value + } +} + +func headVisualeffectIdentity(dataset string, entry autogenManifestEntry, policy headVisualeffectsPolicy) (string, string, string, headVisualeffectsGroupPolicy, bool) { + group, ok := policy.Groups[entry.Group] + if !ok || strings.TrimSpace(entry.ModelStem) == "" { + return "", "", "", headVisualeffectsGroupPolicy{}, false + } + modelStem := strings.TrimSpace(entry.ModelStem) + stem := modelStem + for _, prefix := range policy.StripModelPrefixes { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + continue + } + stem = strings.TrimPrefix(stem, prefix) + } + stem = strings.TrimSpace(stem) + if stem == "" { + return "", "", "", headVisualeffectsGroupPolicy{}, false + } + values := map[string]string{ + "dataset": dataset, + "group": entry.Group, + "prefix": group.Prefix, + "stem": stem, + "stem_upper": strings.ToUpper(stem), + "model_stem": modelStem, + } + 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 expandHeadVisualeffectsFormat(format string, values map[string]string) string { + result := format + for key, value := range values { + result = strings.ReplaceAll(result, "{"+key+"}", value) + } + return result +} + +func createDefaultHeadVisualeffectRow(columns []string, rowID int, key, label, modelStem string, policy headVisualeffectsPolicy, group headVisualeffectsGroupPolicy) map[string]any { row := map[string]any{ "id": rowID, "key": key, @@ -782,28 +908,36 @@ func createDefaultHeadVisualeffectRow(columns []string, rowID int, key, label, m row[column] = nullValue } row["Label"] = label - row["Type_FD"] = "D" - row["OrientWithGround"] = "0" - row["Imp_HeadCon_Node"] = modelStem - row["OrientWithObject"] = "1" + for column, value := range policy.RowDefaults { + row[column] = value + } + for column, value := range group.RowDefaults { + row[column] = value + } + for _, column := range headVisualeffectModelColumns(policy, group) { + row[column] = modelStem + } return row } -func applyDiscoveredHeadVisualeffectDefaults(row map[string]any, columns []string, modelStem, label string) { +func applyDiscoveredHeadVisualeffectDefaults(row map[string]any, columns []string, modelStem, label string, policy headVisualeffectsPolicy, group headVisualeffectsGroupPolicy) { if isNullLikeValue(row["Label"]) { row["Label"] = label } - if isNullLikeValue(row["Type_FD"]) { - row["Type_FD"] = "D" + for column, value := range policy.RowDefaults { + if isNullLikeValue(row[column]) { + row[column] = value + } } - if isNullLikeValue(row["OrientWithGround"]) { - row["OrientWithGround"] = "0" + for column, value := range group.RowDefaults { + if isNullLikeValue(row[column]) { + row[column] = value + } } - if isNullLikeValue(row["Imp_HeadCon_Node"]) { - row["Imp_HeadCon_Node"] = modelStem - } - if isNullLikeValue(row["OrientWithObject"]) { - row["OrientWithObject"] = "1" + for _, column := range headVisualeffectModelColumns(policy, group) { + if isNullLikeValue(row[column]) { + row[column] = modelStem + } } for _, column := range columns { if _, ok := row[column]; !ok { @@ -812,6 +946,16 @@ func applyDiscoveredHeadVisualeffectDefaults(row map[string]any, columns []strin } } +func headVisualeffectModelColumns(policy headVisualeffectsPolicy, group headVisualeffectsGroupPolicy) []string { + if len(group.ModelColumns) > 0 { + return group.ModelColumns + } + if strings.TrimSpace(policy.ModelColumn) == "" { + return nil + } + return []string{policy.ModelColumn} +} + func formatAutogenManifest(root string, producer project.AutogenProducerConfig, repo, ref string, entries []autogenManifestEntry) ([]byte, error) { manifest := autogenManifest{ ID: producer.ID, diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 04d6e95..ab5f340 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -7278,8 +7278,10 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test root := testProjectRoot(t) overrideRoot := filepath.Join(root, "autogen-assets") mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_accessories")) + mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_decorations")) mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_features", "hair")) writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_accessories", "hfx_bandana.mdl"), "mdl\n") + writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_decorations", "hfx_laurel.mdl"), "mdl\n") writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "mdl\n") p := testProject(root) @@ -7290,7 +7292,7 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test Dataset: "visualeffects", Mode: "head_visualeffects", Root: "vfxs", - Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, + Include: []string{"head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.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", @@ -7312,7 +7314,7 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test if err != nil { t.Fatalf("applyAutogenConsumers failed: %v", err) } - if len(got) != 1 || len(got[0].Rows) != 2 { + if len(got) != 1 || len(got[0].Rows) != 3 { t.Fatalf("expected autogenerated visualeffects rows, got %#v", got) } @@ -7329,6 +7331,14 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test t.Fatalf("unexpected head accessory defaults: %#v", bandana) } + laurel := rowByKey["visualeffects:headdecoration_laurel"] + if laurel == nil { + t.Fatalf("expected head decoration row, got %#v", got[0].Rows) + } + if laurel["Label"] != "headdecoration_LAUREL" || laurel["Imp_HeadCon_Node"] != "hfx_laurel" || laurel["Type_FD"] != "D" { + t.Fatalf("unexpected head decoration defaults: %#v", laurel) + } + hair := rowByKey["visualeffects:headfeature_hair_bangs"] if hair == nil { t.Fatalf("expected head feature row, got %#v", got[0].Rows) @@ -7340,18 +7350,113 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test if _, ok := got[0].LockData["visualeffects:headaccessory_bandana"]; !ok { t.Fatalf("expected head accessory lock id, got %#v", got[0].LockData) } + if _, ok := got[0].LockData["visualeffects:headdecoration_laurel"]; !ok { + t.Fatalf("expected head decoration lock id, got %#v", got[0].LockData) + } if _, ok := got[0].LockData["visualeffects:headfeature_hair_bangs"]; !ok { t.Fatalf("expected head feature lock id, 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", "head_jewels")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "base.json"), `{ + "output": "visualeffects.2da", + "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", "head_jewels", "hfx_circlet.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + build: build +topdata: + source: topdata + build: build/topdata +autogen: + consumers: + - id: head_visualeffects + producer: head_visualeffects + dataset: visualeffects + mode: head_visualeffects + root: vfxs + include: + - chest_accessories/**/*.mdl + - head_jewels/**/*.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 + local_override_root: autogen-assets + head_visualeffects: + groups: + chest_accessories: + prefix: chestaccessory + model_columns: + - Imp_Root_S_Node + - Imp_Root_M_Node + - Imp_Root_L_Node + - Imp_Root_H_Node + head_jewels: + prefix: headjewel + label_format: "{prefix}_{stem}" + strip_model_prefixes: + - hfx_ + - tfx_ + row_defaults: + Type_FD: F + OrientWithGround: "0" + OrientWithObject: "0" +`) + + proj, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + + result, err := BuildNative(proj, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + visualeffectsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da")) + if err != nil { + t.Fatalf("read visualeffects.2da: %v", err) + } + text := string(visualeffectsBytes) + for _, want := range []string{ + "0\tchestaccessory_sash\tF\t0\t****\ttfx_sash\ttfx_sash\ttfx_sash\ttfx_sash\t0", + "1\theadjewel_circlet\tF\t0\thfx_circlet\t****\t****\t****\t****\t0", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected configured visualeffects output to contain %q, got:\n%s", want, text) + } + } +} + 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, "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, "topdata", "data", "cachedmodels", "base.json"), `{ "columns": ["Model"], @@ -7374,7 +7479,7 @@ func TestBuildGenerated2DAAssetsAppendsCachedModelsFromHeadVisualeffects(t *test ID: "cachedmodels", Mode: "cachedmodels_rows", Root: "vfxs", - Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, + Include: []string{"chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"}, Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, }, }, @@ -7398,8 +7503,10 @@ func TestBuildGenerated2DAAssetsAppendsCachedModelsFromHeadVisualeffects(t *test for _, want := range []string{ "0\tgrn_m_bone", "1\thfx_existing", - "2\thfx_bandana", - "3\thfx_hair_bangs", + "2\ttfx_sash", + "3\thfx_bandana", + "4\thfx_laurel", + "5\thfx_hair_bangs", } { if !strings.Contains(text, want) { t.Fatalf("expected generated cachedmodels output to contain %q, got:\n%s", want, text)