diff --git a/README.md b/README.md index e58fa6d..0240314 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,10 @@ native-topdata subset packaged as HAK resources. It builds only `.2da` output, injects those generated files into `build-haks`, and rejects TLK-backed text because this path does not produce TLK packages. +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. + For `parts_rows` generated assets, `parts_rows.acbonus` can normalize final ACBONUS values before module overrides are applied. The `ascending_row_id_sort_key` strategy writes toolset ordering values from row diff --git a/internal/project/project.go b/internal/project/project.go index dcb0567..5f73a40 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -1458,7 +1458,7 @@ func validateGeneratedConfig(cfg GeneratedConfig) []error { } failures = append(failures, validateGlobList(fieldPrefix+".include_datasets", top2da.IncludeDatasets)...) switch strings.TrimSpace(top2da.Autogen.Mode) { - case "parts_rows": + case "parts_rows", "cachedmodels_rows": case "": failures = append(failures, fmt.Errorf("%s.autogen.mode is required", fieldPrefix)) default: diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 146450d..3e46f47 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1078,6 +1078,20 @@ func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) { }, }, }, + { + ID: "cachedmodels", + Source: "topdata", + Output: "{paths.cache}/generated-assets/cachedmodels-2da", + IncludeDatasets: []string{"cachedmodels"}, + PackageRoot: "vfxs", + Autogen: AutogenConsumerConfig{ + ID: "cachedmodels", + Mode: "cachedmodels_rows", + Root: "vfxs", + Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, + Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, + }, + }, }, }, }, diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index d177032..2b26f60 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -96,6 +96,10 @@ func autogenConsumerTargetsCollectedDataset(collected []nativeCollectedDataset, if dataset.Dataset.Name == "visualeffects" { return true } + case "cachedmodels_rows": + if dataset.Dataset.Name == "cachedmodels" { + return true + } } } return false @@ -603,6 +607,59 @@ func autogenPartsInventory(entries []autogenManifestEntry) map[string]map[int]st return result } +func augmentWithAutogeneratedCachedModels(collected []nativeCollectedDataset, entries []autogenManifestEntry) ([]nativeCollectedDataset, error) { + if len(entries) == 0 { + return collected, nil + } + + result := append([]nativeCollectedDataset(nil), collected...) + for i, dataset := range result { + if dataset.Dataset.Name != "cachedmodels" { + continue + } + if !slices.Contains(dataset.Columns, "Model") { + return nil, fmt.Errorf("cachedmodels dataset must include Model column for cachedmodels_rows autogen") + } + + rows := make([]map[string]any, len(dataset.Rows), len(dataset.Rows)+len(entries)) + seenModels := make(map[string]struct{}, len(dataset.Rows)+len(entries)) + nextID := 0 + for index, row := range dataset.Rows { + cloned := cloneRowMap(row) + rows[index] = cloned + if rowID, ok := cloned["id"].(int); ok && rowID >= nextID { + nextID = rowID + 1 + } + if model, ok := cloned["Model"].(string); ok { + model = strings.TrimSpace(model) + if model != "" && model != nullValue { + seenModels[strings.ToLower(model)] = struct{}{} + } + } + } + + for _, entry := range entries { + model := strings.TrimSpace(entry.ModelStem) + if model == "" { + continue + } + key := strings.ToLower(model) + if _, exists := seenModels[key]; exists { + continue + } + rows = append(rows, map[string]any{ + "id": nextID, + "Model": model, + }) + seenModels[key] = struct{}{} + nextID++ + } + + result[i].Rows = rows + } + return result, nil +} + func augmentWithAutogeneratedHeadVisualeffects(collected []nativeCollectedDataset, entries []autogenManifestEntry) ([]nativeCollectedDataset, error) { if len(entries) == 0 { return collected, nil diff --git a/internal/topdata/generated_assets.go b/internal/topdata/generated_assets.go index 3c52367..620512b 100644 --- a/internal/topdata/generated_assets.go +++ b/internal/topdata/generated_assets.go @@ -134,6 +134,8 @@ func applyAutogenConsumersForGeneratedAssets(collected []nativeCollectedDataset, switch consumer.Mode { case "parts_rows": return augmentWithAutogeneratedPartsWithConfig(collected, autogenPartsInventory(entries), consumer.PartsRows), nil + case "cachedmodels_rows": + return augmentWithAutogeneratedCachedModels(collected, entries) default: return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode) } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index fe28fe3..02708ae 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -6294,6 +6294,74 @@ func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *test } } +func TestBuildGenerated2DAAssetsAppendsCachedModelsFromHeadVisualeffects(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_accessories")) + 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", "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_features", "hair", "hfx_hair_bangs.mdl"), "mdl\n") + writeFile(t, filepath.Join(root, "topdata", "data", "cachedmodels", "base.json"), `{ + "columns": ["Model"], + "rows": [ + {"id": 0, "Model": "grn_m_bone"}, + {"id": 1, "Model": "hfx_existing"} + ] +}`+"\n") + + p := testProject(root) + p.Config.Paths.Assets = "assets" + p.Config.Generated.TopData2DA = []project.GeneratedTopData2DAConfig{ + { + ID: "cachedmodels", + Source: "topdata", + Output: ".cache/generated-assets/cachedmodels-2da", + IncludeDatasets: []string{"cachedmodels"}, + PackageRoot: "vfxs", + Autogen: project.AutogenConsumerConfig{ + ID: "cachedmodels", + Mode: "cachedmodels_rows", + Root: "vfxs", + Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, + Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, + }, + }, + } + + assets, err := BuildGenerated2DAAssets(p, nil) + if err != nil { + t.Fatalf("BuildGenerated2DAAssets failed: %v", err) + } + if len(assets) != 1 { + t.Fatalf("expected one generated asset, got %#v", assets) + } + if assets[0].Rel != "vfxs/cachedmodels.2da" { + t.Fatalf("expected vfxs/cachedmodels.2da rel, got %q", assets[0].Rel) + } + bytes, err := os.ReadFile(assets[0].SourcePath) + if err != nil { + t.Fatalf("read generated cachedmodels.2da: %v", err) + } + text := string(bytes) + for _, want := range []string{ + "0\tgrn_m_bone", + "1\thfx_existing", + "2\thfx_bandana", + "3\thfx_hair_bangs", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected generated cachedmodels output to contain %q, got:\n%s", want, text) + } + } + if strings.Contains(text, ".mdl") { + t.Fatalf("expected cached model rows without .mdl extension, got:\n%s", text) + } + if strings.Count(text, "hfx_existing") != 1 { + t.Fatalf("expected duplicate discovered model to be skipped, got:\n%s", text) + } +} + func TestNormalizeProjectImportsLegacyLoadscreens(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data"))