Head Accessories AutoGen Configuration

This commit is contained in:
2026-04-25 07:40:17 +02:00
parent 831d3b47da
commit b6fff4bed4
6 changed files with 1010 additions and 10 deletions
+616
View File
@@ -0,0 +1,616 @@
package topdata
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const autogenManifestCacheMaxAge = time.Hour
type autogenManifest struct {
ID string `json:"id"`
Repo string `json:"repo"`
Ref string `json:"ref"`
GeneratedAt string `json:"generated_at"`
Entries []autogenManifestEntry `json:"entries"`
}
type autogenManifestEntry struct {
Source string `json:"source"`
Group string `json:"group,omitempty"`
Subgroup string `json:"subgroup,omitempty"`
ModelStem string `json:"model_stem,omitempty"`
RowID int `json:"row_id,omitempty"`
}
func applyAutogenConsumers(p *project.Project, collected []nativeCollectedDataset, progress func(string)) ([]nativeCollectedDataset, error) {
if len(p.Config.Autogen.Consumers) == 0 {
return collected, nil
}
if progress == nil {
progress = func(string) {}
}
result := append([]nativeCollectedDataset(nil), collected...)
for _, consumer := range p.Config.Autogen.Consumers {
if !autogenConsumerTargetsCollectedDataset(result, consumer) {
continue
}
entries, err := resolveAutogenConsumerEntries(p, consumer, progress)
if err != nil {
if consumer.Optional && isIgnorableOptionalAutogenError(err) {
if progress != nil {
progress(fmt.Sprintf("Skipping optional autogen consumer %s: %v", consumer.ID, err))
}
continue
}
return nil, err
}
if len(entries) == 0 {
continue
}
switch consumer.Mode {
case "parts_rows":
result = augmentWithAutogeneratedParts(result, autogenPartsInventory(entries))
case "head_visualeffects":
result, err = augmentWithAutogeneratedHeadVisualeffects(result, entries)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported autogen consumer mode %q", consumer.Mode)
}
}
return result, nil
}
func autogenConsumerTargetsCollectedDataset(collected []nativeCollectedDataset, consumer project.AutogenConsumerConfig) bool {
for _, dataset := range collected {
switch consumer.Mode {
case "parts_rows":
if isAutogenEligiblePartsDataset(dataset) {
return true
}
case "head_visualeffects":
if dataset.Dataset.Name == "visualeffects" {
return true
}
}
}
return false
}
func isIgnorableOptionalAutogenError(err error) bool {
if err == nil {
return false
}
message := err.Error()
return strings.Contains(message, "HTTP 404") ||
strings.Contains(message, "not found") ||
strings.Contains(message, "entries is empty")
}
func resolveAutogenConsumerEntries(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) ([]autogenManifestEntry, error) {
overrideRoot, err := resolveAutogenLocalOverrideRoot(p, consumer)
if err != nil {
return nil, err
}
if overrideRoot != "" {
if progress != nil {
progress(fmt.Sprintf("Scanning local autogen override for %s from %s...", consumer.ID, overrideRoot))
}
return scanLocalAutogenEntries(overrideRoot, consumer)
}
return resolveReleasedAutogenManifestEntries(p, consumer, progress)
}
func resolveAutogenLocalOverrideRoot(p *project.Project, consumer project.AutogenConsumerConfig) (string, error) {
root := strings.TrimSpace(consumer.LocalOverrideRoot)
if root != "" {
if filepath.IsAbs(root) {
return root, nil
}
return filepath.Join(p.Root, root), nil
}
spec := strings.TrimSpace(p.Config.TopData.Assets)
if spec == "" {
return "", nil
}
if looksLikeGitRepoSpec(spec) {
return "", fmt.Errorf("topdata.assets no longer supports git repo specs for autogen discovery; use a local path override or the default released manifest")
}
path := p.TopDataAssetsDir()
if path == "" {
return "", nil
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("configured topdata.assets path %s is not accessible: %w", path, err)
}
candidate := filepath.Join(path, consumer.Root)
info, err := os.Stat(candidate)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("configured topdata.assets override root %s is not accessible: %w", candidate, err)
}
if !info.IsDir() {
return "", fmt.Errorf("configured topdata.assets override root %s is not a directory", candidate)
}
return path, nil
}
func scanLocalAutogenEntries(overrideRoot string, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
scanRoot := filepath.Join(overrideRoot, consumer.Root)
info, err := os.Stat(scanRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat autogen override root %s: %w", scanRoot, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("autogen override root %s is not a directory", scanRoot)
}
var entries []autogenManifestEntry
err = filepath.Walk(scanRoot, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(scanRoot, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !matchesAutogenInclude(rel, consumer.Include) {
return nil
}
entry, ok := deriveAutogenManifestEntry(rel, consumer.Derive)
if !ok {
return nil
}
entries = append(entries, entry)
return nil
})
if err != nil {
return nil, fmt.Errorf("scan autogen override %s: %w", scanRoot, err)
}
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
return strings.Compare(a.Source, b.Source)
})
return entries, nil
}
func matchesAutogenInclude(rel string, include []string) bool {
rel = filepath.ToSlash(rel)
for _, pattern := range include {
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
if pattern == "" {
continue
}
switch {
case pattern == "**/*.mdl":
if strings.HasSuffix(strings.ToLower(rel), ".mdl") {
return true
}
case strings.HasSuffix(pattern, "/**/*.mdl"):
prefix := strings.TrimSuffix(pattern, "/**/*.mdl")
if strings.HasPrefix(rel, prefix+"/") && strings.HasSuffix(strings.ToLower(rel), ".mdl") {
return true
}
case strings.HasSuffix(pattern, "/*.mdl"):
prefix := strings.TrimSuffix(pattern, "/*.mdl")
dir := filepath.ToSlash(filepath.Dir(rel))
if dir == prefix && strings.HasSuffix(strings.ToLower(rel), ".mdl") {
return true
}
case rel == pattern:
return true
}
}
return false
}
func deriveAutogenManifestEntry(rel string, derive project.AutogenDeriveConfig) (autogenManifestEntry, bool) {
rel = filepath.ToSlash(rel)
stem := strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))
entry := autogenManifestEntry{
Source: rel,
ModelStem: stem,
}
parts := strings.Split(rel, "/")
if derive.GroupFrom == "first_path_segment" && len(parts) > 1 {
entry.Group = parts[0]
if len(parts) > 2 {
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
}
}
switch derive.Kind {
case "trailing_numeric_suffix":
rowID, ok := extractTrailingNumericSuffix(stem)
if !ok {
return autogenManifestEntry{}, false
}
entry.RowID = rowID
case "model_stem":
default:
return autogenManifestEntry{}, false
}
return entry, true
}
func resolveReleasedAutogenManifestEntries(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) ([]autogenManifestEntry, error) {
cachePath := filepath.Join(p.Root, ".cache", consumer.Manifest.CacheName)
if strings.TrimSpace(os.Getenv("SOW_AUTOGEN_MANIFEST_REFRESH")) == "" {
manifest, fresh, err := readFreshAutogenManifestCache(cachePath, autogenManifestCacheMaxAge)
if err != nil {
if progress != nil {
progress(fmt.Sprintf("Ignoring cached autogen manifest %s: %v", consumer.Manifest.CacheName, err))
}
} else if fresh {
if progress != nil {
progress(fmt.Sprintf("Using cached released autogen manifest %s...", cachePath))
}
return manifest.Entries, nil
}
}
spec, err := deriveSowAssetsRepoSpec(p.Root)
if err != nil {
return nil, err
}
manifestURL, err := resolveAutogenManifestAssetURL(spec, consumer.Manifest.ReleaseTag, consumer.Manifest.AssetName)
if err != nil {
if consumer.Mode == "parts_rows" {
return nil, fmt.Errorf("%s", strings.Replace(err.Error(), "autogen manifest", "parts manifest", 1))
}
return nil, err
}
if progress != nil {
progress(fmt.Sprintf("Fetching released autogen manifest from %s...", manifestURL))
}
manifest, err := fetchAutogenManifest(manifestURL)
if err != nil {
if consumer.Mode == "parts_rows" {
return nil, fmt.Errorf("%s", strings.Replace(err.Error(), "autogen manifest", "parts manifest", 1))
}
return nil, err
}
if manifest.ID != "" && manifest.ID != consumer.Producer {
return nil, fmt.Errorf("autogen manifest %s declared id %q, expected %q", consumer.Manifest.AssetName, manifest.ID, consumer.Producer)
}
if err := writeAutogenManifestCache(cachePath, manifest); err != nil {
return nil, err
}
return manifest.Entries, nil
}
func resolveAutogenManifestAssetURL(spec giteaRepoSpec, releaseTag, assetName string) (string, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(spec.BaseURL, "/"), releasePathEscape(spec.Owner), releasePathEscape(spec.Repo), releasePathEscape(releaseTag))
var release struct {
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
DownloadURL string `json:"download_url"`
} `json:"assets"`
}
if err := fetchJSON(apiURL, &release); err != nil {
return "", fmt.Errorf("fetch autogen manifest release metadata: %w", err)
}
for _, asset := range release.Assets {
if asset.Name != assetName {
continue
}
if asset.DownloadURL != "" {
return asset.DownloadURL, nil
}
if asset.BrowserDownloadURL != "" {
return asset.BrowserDownloadURL, nil
}
}
return "", fmt.Errorf("autogen manifest asset %s not found in release %s/%s:%s", assetName, spec.Owner, spec.Repo, releaseTag)
}
func releasePathEscape(value string) string {
replacer := strings.NewReplacer("/", "%2F")
return replacer.Replace(value)
}
func fetchAutogenManifest(manifestURL string) (*autogenManifest, error) {
var manifest autogenManifest
if err := fetchJSON(manifestURL, &manifest); err != nil {
return nil, fmt.Errorf("download autogen manifest: %w", err)
}
if len(manifest.Entries) == 0 {
var legacy partsManifest
if err := fetchJSON(manifestURL, &legacy); err == nil && len(legacy.Categories) > 0 {
return legacyPartsManifestToAutogen(&legacy), nil
}
}
if len(manifest.Entries) == 0 {
return nil, fmt.Errorf("download autogen manifest: entries is empty")
}
return &manifest, nil
}
func readFreshAutogenManifestCache(path string, maxAge time.Duration) (*autogenManifest, bool, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
if maxAge > 0 && time.Since(info.ModTime()) > maxAge {
return nil, false, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, false, err
}
var manifest autogenManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, false, err
}
if len(manifest.Entries) == 0 {
var legacy partsManifest
if err := json.Unmarshal(raw, &legacy); err == nil && len(legacy.Categories) > 0 {
return legacyPartsManifestToAutogen(&legacy), true, nil
}
}
if len(manifest.Entries) == 0 {
return nil, false, fmt.Errorf("entries is empty")
}
return &manifest, true, nil
}
func legacyPartsManifestToAutogen(legacy *partsManifest) *autogenManifest {
entries := make([]autogenManifestEntry, 0)
for _, category := range supportedPartCategories {
for _, rowID := range legacy.Categories[category] {
entries = append(entries, autogenManifestEntry{
Group: category,
RowID: rowID,
})
}
}
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
if cmp := strings.Compare(a.Group, b.Group); cmp != 0 {
return cmp
}
return a.RowID - b.RowID
})
return &autogenManifest{
ID: "parts",
Repo: legacy.Repo,
Ref: legacy.Ref,
GeneratedAt: legacy.GeneratedAt,
Entries: entries,
}
}
func writeAutogenManifestCache(path string, manifest *autogenManifest) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create autogen manifest cache dir: %w", err)
}
payload, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("marshal autogen manifest cache: %w", err)
}
payload = append(payload, '\n')
current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, payload) {
now := time.Now()
if err := os.Chtimes(path, now, now); err != nil {
return fmt.Errorf("refresh autogen manifest cache timestamp: %w", err)
}
return nil
}
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read autogen manifest cache: %w", err)
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, payload, 0o644); err != nil {
return fmt.Errorf("write autogen manifest cache: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replace autogen manifest cache: %w", err)
}
return nil
}
func autogenPartsInventory(entries []autogenManifestEntry) map[string]map[int]struct{} {
result := make(map[string]map[int]struct{}, len(supportedPartCategories))
for _, category := range supportedPartCategories {
result[category] = map[int]struct{}{}
}
for _, entry := range entries {
if !isSupportedPartCategory(entry.Group) || entry.RowID <= 0 {
continue
}
result[entry.Group][entry.RowID] = struct{}{}
}
return result
}
func augmentWithAutogeneratedHeadVisualeffects(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 != "visualeffects" {
continue
}
rows := make([]map[string]any, len(dataset.Rows))
rowByKey := make(map[string]map[string]any, len(dataset.Rows))
usedIDs := make(map[int]struct{}, len(dataset.Rows)+len(dataset.LockData))
lockData := make(map[string]int, len(dataset.LockData))
for key, rowID := range dataset.LockData {
lockData[key] = rowID
usedIDs[rowID] = struct{}{}
}
for index, row := range dataset.Rows {
cloned := cloneRowMap(row)
rows[index] = cloned
if key, ok := cloned["key"].(string); ok && key != "" {
rowByKey[key] = cloned
}
if rowID, ok := cloned["id"].(int); ok {
usedIDs[rowID] = struct{}{}
}
}
nextID := nextAvailableAutogenID(usedIDs)
allocateNextID := func() int {
rowID := nextID
usedIDs[rowID] = struct{}{}
nextID = nextAvailableAutogenID(usedIDs)
return rowID
}
for _, entry := range entries {
key, label, ok := headVisualeffectIdentity(entry)
if !ok {
continue
}
if existing, exists := rowByKey[key]; exists {
applyDiscoveredHeadVisualeffectDefaults(existing, dataset.Columns, entry.ModelStem, label)
continue
}
rowID, ok := lockData[key]
if !ok {
rowID = allocateNextID()
lockData[key] = rowID
}
newRow := createDefaultHeadVisualeffectRow(dataset.Columns, rowID, key, label, entry.ModelStem)
rows = append(rows, newRow)
rowByKey[key] = newRow
}
slices.SortFunc(rows, func(a, b map[string]any) int {
return a["id"].(int) - b["id"].(int)
})
result[i].Rows = rows
result[i].LockData = lockData
result[i].LockModified = !lockDataEqual(dataset.LockData, lockData)
}
return result, nil
}
func nextAvailableAutogenID(used map[int]struct{}) int {
for rowID := 0; ; rowID++ {
if _, ok := used[rowID]; !ok {
return rowID
}
}
}
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
}
func createDefaultHeadVisualeffectRow(columns []string, rowID int, key, label, modelStem string) map[string]any {
row := map[string]any{
"id": rowID,
"key": key,
}
for _, column := range columns {
row[column] = nullValue
}
row["Label"] = label
row["Type_FD"] = "D"
row["OrientWithGround"] = "0"
row["Imp_HeadCon_Node"] = modelStem
row["OrientWithObject"] = "1"
return row
}
func applyDiscoveredHeadVisualeffectDefaults(row map[string]any, columns []string, modelStem, label string) {
if isNullLikeValue(row["Label"]) {
row["Label"] = label
}
if isNullLikeValue(row["Type_FD"]) {
row["Type_FD"] = "D"
}
if isNullLikeValue(row["OrientWithGround"]) {
row["OrientWithGround"] = "0"
}
if isNullLikeValue(row["Imp_HeadCon_Node"]) {
row["Imp_HeadCon_Node"] = modelStem
}
if isNullLikeValue(row["OrientWithObject"]) {
row["OrientWithObject"] = "1"
}
for _, column := range columns {
if _, ok := row[column]; !ok {
row[column] = nullValue
}
}
}
func formatAutogenManifest(root string, producer project.AutogenProducerConfig, repo, ref string, entries []autogenManifestEntry) ([]byte, error) {
manifest := autogenManifest{
ID: producer.ID,
Repo: repo,
Ref: ref,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Entries: entries,
}
return json.MarshalIndent(manifest, "", " ")
}
func parseAutogenManifestEntryList(raw []byte) ([]autogenManifestEntry, error) {
var manifest autogenManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, err
}
return manifest.Entries, nil
}
func normalizeAutogenNumericValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case int:
return strconv.Itoa(typed)
case float64:
return strconv.Itoa(int(typed))
default:
return fmt.Sprintf("%v", value)
}
}
+3 -10
View File
@@ -284,16 +284,9 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
}
}
}
if hasCollectedPartsDatasets(collected) {
progress("Resolving released parts manifest...")
autogenerated, err := resolveAutogeneratedPartsInventory(p, progress)
if err != nil {
return BuildResult{}, err
}
if autogenerated != nil {
collected = augmentWithAutogeneratedParts(collected, autogenerated)
progress("Augmented parts datasets with discovered models")
}
collected, err = applyAutogenConsumers(p, collected, progress)
if err != nil {
return BuildResult{}, err
}
collected, err = applyPartOverrides(sourceDir, collected)
if err != nil {
+16
View File
@@ -60,6 +60,19 @@ func isPartsDataset(datasetName string) bool {
return false
}
func isAutogenEligiblePartsDataset(dataset nativeCollectedDataset) bool {
if !isPartsDataset(dataset.Dataset.Name) {
return false
}
for _, row := range dataset.Rows {
key, ok := row["key"].(string)
if ok && strings.TrimSpace(key) != "" {
return false
}
}
return true
}
// extractPartCategory extracts the part category from a dataset name
// e.g., "parts/belt" -> "belt", "parts/bicep" -> "bicep"
func extractPartCategory(datasetName string) string {
@@ -430,6 +443,9 @@ func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogener
copy(result, collected)
for i, dataset := range collected {
if !isAutogenEligiblePartsDataset(dataset) {
continue
}
category := partAssetCategoryForDataset(dataset.Dataset.Name)
if !isSupportedPartCategory(category) {
continue
+196
View File
@@ -5479,6 +5479,121 @@ with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
}
}
func TestApplyAutogenConsumersAugmentsPartsFromLocalOverride(t *testing.T) {
root := testProjectRoot(t)
overrideRoot := filepath.Join(root, "autogen-assets")
mkdirAll(t, filepath.Join(overrideRoot, "part", "belt"))
writeFile(t, filepath.Join(overrideRoot, "part", "belt", "pfa0_belt018.mdl"), "mdl\n")
p := testProject(root)
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
{
ID: "parts",
Producer: "parts",
Dataset: "parts",
Mode: "parts_rows",
Root: "part",
Include: []string{"**/*.mdl"},
Derive: project.AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
Manifest: project.AutogenManifestConfig{ReleaseTag: "parts-manifest-current", AssetName: "sow-parts-manifest.json", CacheName: "sow-parts-manifest.json"},
LocalOverrideRoot: "autogen-assets",
},
}
collected := []nativeCollectedDataset{
{
Dataset: nativeDataset{Name: "parts/belt"},
Columns: []string{"COSTMODIFIER", "ACBONUS"},
Rows: []map[string]any{
{"id": 0, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
},
},
}
got, err := applyAutogenConsumers(p, collected, nil)
if err != nil {
t.Fatalf("applyAutogenConsumers failed: %v", err)
}
if len(got) != 1 || len(got[0].Rows) != 2 {
t.Fatalf("expected autogenerated part row, got %#v", got)
}
row := got[0].Rows[1]
if row["id"] != 18 || row["COSTMODIFIER"] != "0" || row["ACBONUS"] != "0.00" {
t.Fatalf("unexpected autogenerated part row: %#v", row)
}
}
func TestApplyAutogenConsumersAugmentsHeadVisualeffectsFromLocalOverride(t *testing.T) {
root := testProjectRoot(t)
overrideRoot := filepath.Join(root, "autogen-assets")
mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_accessories"))
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_features", "hair", "hfx_hair_bangs.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{"head_accessories/**/*.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",
},
}
collected := []nativeCollectedDataset{
{
Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase},
Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"},
Rows: nil,
LockData: map[string]int{
"visualeffects:existing": 17,
},
},
}
got, err := applyAutogenConsumers(p, collected, nil)
if err != nil {
t.Fatalf("applyAutogenConsumers failed: %v", err)
}
if len(got) != 1 || len(got[0].Rows) != 2 {
t.Fatalf("expected autogenerated visualeffects rows, got %#v", got)
}
rowByKey := map[string]map[string]any{}
for _, row := range got[0].Rows {
rowByKey[row["key"].(string)] = row
}
bandana := rowByKey["visualeffects:headaccessory_bandana"]
if bandana == nil {
t.Fatalf("expected head accessory row, got %#v", got[0].Rows)
}
if bandana["Label"] != "headaccessory_BANDANA" || bandana["Imp_HeadCon_Node"] != "hfx_bandana" || bandana["OrientWithObject"] != "1" {
t.Fatalf("unexpected head accessory defaults: %#v", bandana)
}
hair := rowByKey["visualeffects:headfeature_hair_bangs"]
if hair == nil {
t.Fatalf("expected head feature row, got %#v", got[0].Rows)
}
if hair["Label"] != "headfeature_HAIR_BANGS" || hair["Imp_HeadCon_Node"] != "hfx_hair_bangs" || hair["Type_FD"] != "D" {
t.Fatalf("unexpected head feature defaults: %#v", hair)
}
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:headfeature_hair_bangs"]; !ok {
t.Fatalf("expected head feature lock id, got %#v", got[0].LockData)
}
}
func TestNormalizeProjectImportsLegacyLoadscreens(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data"))
@@ -9028,6 +9143,27 @@ func TestBuildNativeWithReleasedPartsManifest(t *testing.T) {
Source: "topdata",
Build: "build/topdata",
},
Autogen: project.AutogenConfig{
Consumers: []project.AutogenConsumerConfig{
{
ID: "parts",
Producer: "parts",
Dataset: "parts",
Mode: "parts_rows",
Root: "part",
Include: []string{"**/*.mdl"},
Derive: project.AutogenDeriveConfig{
Kind: "trailing_numeric_suffix",
GroupFrom: "first_path_segment",
},
Manifest: project.AutogenManifestConfig{
ReleaseTag: "parts-manifest-current",
AssetName: "sow-parts-manifest.json",
CacheName: "sow-parts-manifest.json",
},
},
},
},
},
}
@@ -9105,6 +9241,27 @@ func TestBuildNativeFailsWhenReleasedPartsManifestUnavailable(t *testing.T) {
Source: "topdata",
Build: "build/topdata",
},
Autogen: project.AutogenConfig{
Consumers: []project.AutogenConsumerConfig{
{
ID: "parts",
Producer: "parts",
Dataset: "parts",
Mode: "parts_rows",
Root: "part",
Include: []string{"**/*.mdl"},
Derive: project.AutogenDeriveConfig{
Kind: "trailing_numeric_suffix",
GroupFrom: "first_path_segment",
},
Manifest: project.AutogenManifestConfig{
ReleaseTag: "parts-manifest-current",
AssetName: "sow-parts-manifest.json",
CacheName: "sow-parts-manifest.json",
},
},
},
},
},
}
@@ -9564,6 +9721,45 @@ func testProject(root string) *project.Project {
ReferenceBuilder: "reference",
Assets: ".",
},
Autogen: project.AutogenConfig{
Consumers: []project.AutogenConsumerConfig{
{
ID: "parts",
Producer: "parts",
Dataset: "parts",
Mode: "parts_rows",
Root: "part",
Include: []string{"**/*.mdl"},
Derive: project.AutogenDeriveConfig{
Kind: "trailing_numeric_suffix",
GroupFrom: "first_path_segment",
},
Manifest: project.AutogenManifestConfig{
ReleaseTag: "parts-manifest-current",
AssetName: "sow-parts-manifest.json",
CacheName: "sow-parts-manifest.json",
},
},
{
ID: "head_visualeffects",
Producer: "head_visualeffects",
Dataset: "visualeffects",
Mode: "head_visualeffects",
Optional: true,
Root: "vfxs",
Include: []string{"head_accessories/**/*.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",
},
},
},
},
},
}
}