package topdata import ( "encoding/json" "fmt" "os" "path/filepath" "slices" "strconv" "strings" ) const ( itempropsRegistryDirName = "itemprops/registry" itempropsRegistryLock = "lock.json" ) var itempropsAvailabilityColumns = []string{ "0_Melee", "1_Ranged", "2_Thrown", "3_Staves", "4_Rods", "5_Ammo", "6_Arm_Shld", "7_Helm", "8_Potions", "9_Scrolls", "10_Wands", "11_Thieves", "12_TrapKits", "13_Hide", "14_Claw", "15_Misc_Uneq", "16_Misc", "17_No_Props", "18_Containers", "19_HealerKit", "20_Torch", "21_Glove", } var itempropsAvailabilityAliases = map[string]string{ "0_melee": "melee", "1_ranged": "ranged", "2_thrown": "thrown", "3_staves": "staves", "4_rods": "rods", "5_ammo": "ammo", "6_arm_shld": "arm_shld", "7_helm": "helm", "8_potions": "potions", "9_scrolls": "scrolls", "10_wands": "wands", "11_thieves": "thieves", "12_trapkits": "trapkits", "13_hide": "hide", "14_claw": "claw", "15_misc_uneq": "misc_uneq", "16_misc": "misc", "17_no_props": "no_props", "18_containers": "containers", "19_healerkit": "healerkit", "20_torch": "torch", "21_glove": "glove", } var itempropsAvailabilityToColumn = map[string]string{ "melee": "0_Melee", "ranged": "1_Ranged", "thrown": "2_Thrown", "staves": "3_Staves", "rods": "4_Rods", "ammo": "5_Ammo", "arm_shld": "6_Arm_Shld", "helm": "7_Helm", "potions": "8_Potions", "scrolls": "9_Scrolls", "wands": "10_Wands", "thieves": "11_Thieves", "trapkits": "12_TrapKits", "hide": "13_Hide", "claw": "14_Claw", "misc_uneq": "15_Misc_Uneq", "misc": "16_Misc", "no_props": "17_No_Props", "containers": "18_Containers", "healerkit": "19_HealerKit", "torch": "20_Torch", "glove": "21_Glove", } type itempropsRegistry struct { RootPath string LockPath string LockData map[string]int Properties []map[string]any CostModels []map[string]any ParamModels []map[string]any SubtypeModels []map[string]any } func loadItempropsRegistry(dataDir string) (*itempropsRegistry, error) { root := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)) info, err := os.Stat(root) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } if !info.IsDir() { return nil, fmt.Errorf("itemprops registry root must be a directory: %s", root) } lockData, err := loadLockfile(filepath.Join(root, itempropsRegistryLock)) if err != nil { return nil, err } properties, err := loadRegistryRows(filepath.Join(root, "properties.json")) if err != nil { return nil, err } costModels, err := loadRegistryRows(filepath.Join(root, "cost_models.json")) if err != nil { return nil, err } paramModels, err := loadRegistryRows(filepath.Join(root, "param_models.json")) if err != nil { return nil, err } subtypeModels, err := loadRegistryRows(filepath.Join(root, "subtype_models.json")) if err != nil { return nil, err } return &itempropsRegistry{ RootPath: root, LockPath: filepath.Join(root, itempropsRegistryLock), LockData: lockData, Properties: properties, CostModels: costModels, ParamModels: paramModels, SubtypeModels: subtypeModels, }, nil } func loadRegistryRows(path string) ([]map[string]any, error) { obj, err := loadJSONObject(path) if err != nil { return nil, err } rawRows, ok := obj["rows"].([]any) if !ok { return nil, fmt.Errorf("%s: rows must be an array", path) } rows := make([]map[string]any, 0, len(rawRows)) for index, raw := range rawRows { row, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("%s: row %d must be an object", path, index) } rows = append(rows, row) } return rows, nil } func collectItempropsRegistryDatasets(dataDir string, persistLocks bool) ([]nativeCollectedDataset, error) { registry, err := loadItempropsRegistry(dataDir) if err != nil { return nil, err } if registry == nil { return nil, nil } propertyIDs, lockModified, err := assignRegistryIDs(registry.Properties, registry.LockData, "itemprop:") if err != nil { return nil, err } costIDs, costModified, err := assignRegistryIDs(registry.CostModels, registry.LockData, "cost_models:") if err != nil { return nil, err } paramIDs, paramModified, err := assignRegistryIDs(registry.ParamModels, registry.LockData, "param_models:") if err != nil { return nil, err } lockModified = lockModified || costModified || paramModified if lockModified && persistLocks { if err := saveLockfile(registry.LockPath, registry.LockData); err != nil { return nil, err } } subtypeByKey := map[string]map[string]any{} for _, row := range registry.SubtypeModels { key := stringField(row, "key") if key != "" { subtypeByKey[key] = row } } costByKey := map[string]map[string]any{} for _, row := range registry.CostModels { key := stringField(row, "key") if key != "" { costByKey[key] = row } } paramByKey := map[string]map[string]any{} for _, row := range registry.ParamModels { key := stringField(row, "key") if key != "" { paramByKey[key] = row } } datasets := make([]nativeCollectedDataset, 0) itempropsRows := make([]map[string]any, 0, len(registry.Properties)) itempropdefRows := make([]map[string]any, 0, len(registry.Properties)) propertyRowsSorted := slices.Clone(registry.Properties) slices.SortFunc(propertyRowsSorted, func(a, b map[string]any) int { return propertyIDs[stringField(a, "key")] - propertyIDs[stringField(b, "key")] }) for _, property := range propertyRowsSorted { key := stringField(property, "key") rowID := propertyIDs[key] itempropsRow := map[string]any{ "id": rowID, "key": "itemprops:" + strings.TrimPrefix(key, "itemprop:"), "StringRef": itempropsSetName(property), "Label": itempropsSetLabel(property), } for _, column := range itempropsAvailabilityColumns { itempropsRow[column] = nullValue } availability, err := canonicalAvailability(property) if err != nil { return nil, fmt.Errorf("%s: %w", key, err) } for _, group := range availability { column := itempropsAvailabilityToColumn[group] itempropsRow[column] = "1" } itempropsRows = append(itempropsRows, itempropsRow) subtypeResRef := nullValue if subtypeKey, ok := refField(property["subtype"]); ok { subtype, ok := subtypeByKey[subtypeKey] if !ok { return nil, fmt.Errorf("%s: unknown subtype ref %s", key, subtypeKey) } subtypeResRef = stringField(subtype, "table_resref") if subtypeResRef == "" { return nil, fmt.Errorf("%s: subtype model %s is missing table_resref", key, subtypeKey) } } costValue := nullValue costRefID := nullValue if rawCost, ok := property["cost"].(map[string]any); ok { if value, ok := rawCost["value"]; ok { costValue = formatRegistryScalar(value) } costRefKey, ok := refField(rawCost) if ok { id, ok := costIDs[costRefKey] if !ok { return nil, fmt.Errorf("%s: unknown cost ref %s", key, costRefKey) } costRefID = strconv.Itoa(id) } } paramRefID := nullValue if paramKey, ok := refField(property["param"]); ok { id, ok := paramIDs[paramKey] if !ok { return nil, fmt.Errorf("%s: unknown param ref %s", key, paramKey) } paramRefID = strconv.Itoa(id) } itempropdefRows = append(itempropdefRows, map[string]any{ "id": rowID, "key": "itempropdef:" + strings.TrimPrefix(key, "itemprop:"), "Name": deepCopyValue(property["name"]), "Label": itempropsDefLabel(property), "SubTypeResRef": subtypeResRef, "Cost": costValue, "CostTableResRef": costRefID, "Param1ResRef": paramRefID, "GameStrRef": deepCopyValue(property["property_text"]), "Description": nullableRegistryField(property, "description"), }) } costRows := make([]map[string]any, 0, len(registry.CostModels)) costRowsSorted := slices.Clone(registry.CostModels) slices.SortFunc(costRowsSorted, func(a, b map[string]any) int { return costIDs[stringField(a, "key")] - costIDs[stringField(b, "key")] }) for _, costModel := range costRowsSorted { key := stringField(costModel, "key") costRows = append(costRows, map[string]any{ "id": costIDs[key], "key": key, "Name": stringField(costModel, "index_name"), "Label": stringField(costModel, "label"), "ClientLoad": formatRegistryScalar(costModel["client_load"]), }) } paramRows := make([]map[string]any, 0, len(registry.ParamModels)) paramRowsSorted := slices.Clone(registry.ParamModels) slices.SortFunc(paramRowsSorted, func(a, b map[string]any) int { return paramIDs[stringField(a, "key")] - paramIDs[stringField(b, "key")] }) for _, paramModel := range paramRowsSorted { key := stringField(paramModel, "key") table, ok := paramModel["table"].(map[string]any) if !ok { return nil, fmt.Errorf("%s: param model missing table", key) } paramRows = append(paramRows, map[string]any{ "id": paramIDs[key], "key": key, "Name": nullableRegistryField(paramModel, "name"), "Lable": stringField(paramModel, "label"), "TableResRef": stringField(table, "resref"), }) } datasets = append(datasets, newGeneratedDataset("itemprops/registry/itemprops", "itemprops.2da", itempropsAvailabilityColumnsWithMeta(), itempropsRows), newGeneratedDataset("itemprops/registry/itempropdef", "itempropdef.2da", []string{"Name", "Label", "SubTypeResRef", "Cost", "CostTableResRef", "Param1ResRef", "GameStrRef", "Description"}, itempropdefRows), newGeneratedDataset("itemprops/registry/costtable_index", "iprp_costtable.2da", []string{"Name", "Label", "ClientLoad"}, costRows), newGeneratedDataset("itemprops/registry/paramtable_index", "iprp_paramtable.2da", []string{"Name", "Lable", "TableResRef"}, paramRows), ) auxDatasets, err := collectItempropsAuxiliaryDatasets(registry.CostModels, "cost_models", "itemprops/registry/cost_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.ParamModels, "param_models", "itemprops/registry/param_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.SubtypeModels, "subtype_models", "itemprops/registry/subtype_models") if err != nil { return nil, err } datasets = append(datasets, auxDatasets...) return datasets, nil } func newGeneratedDataset(name, outputName string, columns []string, rows []map[string]any) nativeCollectedDataset { return nativeCollectedDataset{ Dataset: nativeDataset{ Kind: nativeDatasetPlain, Name: name, OutputName: outputName, Spec: specForDataset(name), }, Columns: columns, Rows: rows, LockData: map[string]int{}, } } func itempropsAvailabilityColumnsWithMeta() []string { columns := append([]string{}, itempropsAvailabilityColumns...) columns = append(columns, "StringRef", "Label") return columns } func collectItempropsAuxiliaryDatasets(models []map[string]any, keyPrefix, namePrefix string) ([]nativeCollectedDataset, error) { out := make([]nativeCollectedDataset, 0) for _, model := range models { key := stringField(model, "key") table, ok := model["table"].(map[string]any) if !ok { continue } if !strings.EqualFold(stringField(table, "ownership"), "owned") { continue } outputName := stringField(table, "output") if outputName == "" { outputName = stringField(table, "resref") + ".2da" } rawColumns, ok := table["columns"].([]any) if !ok { return nil, fmt.Errorf("%s: owned table is missing columns", key) } columns := make([]string, 0, len(rawColumns)) for _, rawColumn := range rawColumns { column, ok := rawColumn.(string) if !ok { return nil, fmt.Errorf("%s: owned table columns must be strings", key) } columns = append(columns, column) } rawRows, ok := table["rows"].([]any) if !ok { return nil, fmt.Errorf("%s: owned table rows must be an array", key) } rows := make([]map[string]any, 0, len(rawRows)) for index, rawRow := range rawRows { row, ok := rawRow.(map[string]any) if !ok { return nil, fmt.Errorf("%s: owned table row %d must be an object", key, index) } canonical, err := canonicalizeBaseRow(nativeDataset{Name: keyPrefix + "/" + strings.TrimPrefix(key, keyPrefix+":"), Spec: specForDataset("itemprops")}, columns, row, index) if err != nil { return nil, err } rows = append(rows, canonical) } out = append(out, newGeneratedDataset(namePrefix+"/"+strings.TrimPrefix(key, keyPrefix+":"), outputName, columns, rows)) } return out, nil } func assignRegistryIDs(rows []map[string]any, lockData map[string]int, requiredPrefix string) (map[string]int, bool, error) { ids := map[string]int{} used := map[int]struct{}{} for key, id := range lockData { if strings.HasPrefix(key, requiredPrefix) { used[id] = struct{}{} } } modified := false for _, row := range rows { key := stringField(row, "key") if key == "" { return nil, false, fmt.Errorf("registry row is missing key") } if !strings.HasPrefix(key, requiredPrefix) { return nil, false, fmt.Errorf("registry key %q must use prefix %q", key, requiredPrefix) } if id, ok := lockData[key]; ok { ids[key] = id used[id] = struct{}{} continue } } nextID := nextAvailableID(used) for _, row := range rows { key := stringField(row, "key") if _, ok := ids[key]; ok { continue } lockData[key] = nextID ids[key] = nextID used[nextID] = struct{}{} nextID = nextAvailableID(used) modified = true } return ids, modified, nil } func canonicalAvailability(row map[string]any) ([]string, error) { rawAvailability, ok := row["availability"] if !ok { return nil, nil } rawList, ok := rawAvailability.([]any) if !ok { return nil, fmt.Errorf("availability must be an array") } out := make([]string, 0, len(rawList)) seen := map[string]struct{}{} for _, raw := range rawList { text, ok := raw.(string) if !ok { return nil, fmt.Errorf("availability values must be strings") } normalized := normalizeAvailabilityName(text) if _, ok := itempropsAvailabilityToColumn[normalized]; !ok { return nil, fmt.Errorf("unknown availability group %q", text) } if _, ok := seen[normalized]; ok { continue } seen[normalized] = struct{}{} out = append(out, normalized) } slices.Sort(out) return out, nil } func normalizeAvailabilityName(name string) string { normalized := strings.TrimSpace(strings.ToLower(name)) normalized = strings.ReplaceAll(normalized, " ", "_") normalized = strings.ReplaceAll(normalized, "-", "_") if alias, ok := itempropsAvailabilityAliases[normalized]; ok { return alias } return normalized } func refField(value any) (string, bool) { if value == nil { return "", false } refMap, ok := value.(map[string]any) if !ok { return "", false } rawRef, ok := refMap["ref"] if !ok { return "", false } text, ok := rawRef.(string) return text, ok && strings.TrimSpace(text) != "" } func stringField(obj map[string]any, field string) string { raw, ok := obj[field] if !ok || raw == nil { return "" } switch typed := raw.(type) { case string: return typed case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) default: return "" } } func nullableRegistryField(obj map[string]any, field string) any { value, ok := obj[field] if !ok || value == nil { return nullValue } if text, ok := value.(string); ok && strings.TrimSpace(text) == "" { return nullValue } return deepCopyValue(value) } func itempropsDefLabel(obj map[string]any) string { return stringField(obj, "label") } func itempropsSetName(obj map[string]any) any { if value, ok := obj["set_name"]; ok { return deepCopyValue(value) } return deepCopyValue(obj["name"]) } func itempropsSetLabel(obj map[string]any) string { if label := stringField(obj, "set_label"); label != "" { return label } return itempropsDefLabel(obj) } func formatRegistryScalar(value any) string { if value == nil { return nullValue } switch typed := value.(type) { case string: if strings.TrimSpace(typed) == "" { return nullValue } return typed case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) case int: return strconv.Itoa(typed) default: return fmt.Sprint(typed) } } func deepCopyValue(value any) any { raw, err := json.Marshal(value) if err != nil { return value } var out any if err := json.Unmarshal(raw, &out); err != nil { return value } return out } func importLegacyItempropsRegistry(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) { legacyDataDir := filepath.Join(referenceBuilderDir, "data") registryDir := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)) if _, err := os.Stat(filepath.Join(registryDir, "properties.json")); err == nil { if refs, err := countLegacyTLKRefsInRegistry(registryDir); err == nil && refs == 0 { return 0, nil } } if _, err := os.Stat(filepath.Join(legacyDataDir, "itemprops")); err != nil { if os.IsNotExist(err) { return 0, nil } return 0, err } defs, err := collectBaseDataset(nativeDataset{ Name: "itemprops/defs", BasePath: filepath.Join(legacyDataDir, "itemprops", "defs", "base.json"), LockPath: filepath.Join(legacyDataDir, "itemprops", "defs", "lock.json"), ModulesDir: filepath.Join(legacyDataDir, "itemprops", "defs", "modules"), Spec: specForDataset("itemprops"), }) if err != nil { return 0, err } sets, err := collectBaseDataset(nativeDataset{ Name: "itemprops/sets", BasePath: filepath.Join(legacyDataDir, "itemprops", "sets", "base.json"), LockPath: filepath.Join(legacyDataDir, "itemprops", "sets", "lock.json"), ModulesDir: filepath.Join(legacyDataDir, "itemprops", "sets", "modules"), Spec: specForDataset("itemprops"), }) if err != nil { return 0, err } costIndex, err := collectBaseDataset(nativeDataset{ Name: "itemprops/costtables/index", BasePath: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "base.json"), LockPath: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "lock.json"), ModulesDir: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "modules"), Spec: specForDataset("itemprops"), }) if err != nil { return 0, err } paramIndex, err := collectBaseDataset(nativeDataset{ Name: "itemprops/paramtables/index", BasePath: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "base.json"), LockPath: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "lock.json"), ModulesDir: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "modules"), Spec: specForDataset("itemprops"), }) if err != nil { return 0, err } ownedCostTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "costtables"), "index") if err != nil { return 0, err } ownedParamTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "paramtables"), "index") if err != nil { return 0, err } ownedSubtypeTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "subtypes"), "") if err != nil && !os.IsNotExist(err) { return 0, err } setsByID := map[int]map[string]any{} for _, row := range sets.Rows { setsByID[row["id"].(int)] = row } costByID := map[int]map[string]any{} for _, row := range costIndex.Rows { costByID[row["id"].(int)] = row } paramByID := map[int]map[string]any{} for _, row := range paramIndex.Rows { paramByID[row["id"].(int)] = row } registryLock := map[string]int{} legacyKeyByID := map[string]string{} if legacyTLK != nil { for key, id := range legacyTLK.Lock { legacyKeyByID[strconv.Itoa(id)] = key } } properties := make([]map[string]any, 0, len(defs.Rows)) subtypeRegistryByKey := map[string]map[string]any{} for _, row := range defs.Rows { rowID := row["id"].(int) key := canonicalItempropPropertyKey(row) registryLock[key] = rowID property := map[string]any{ "key": key, "label": row["Label"], "name": deepCopyValue(row["Name"]), "property_text": deepCopyValue(row["GameStrRef"]), } if description := nullableRegistryField(row, "Description"); description != nullValue { property["description"] = description } if setRow, ok := setsByID[rowID]; ok { property["availability"] = importLegacyAvailability(setRow) if setName := nullableRegistryField(setRow, "StringRef"); setName != nullValue && setName != stringField(row, "Name") { property["set_name"] = setName } if setLabel := stringField(setRow, "Label"); setLabel != "" && setLabel != stringField(row, "Label") { property["set_label"] = setLabel } } else { property["availability"] = []any{} } if subtypeResRef := stringField(row, "SubTypeResRef"); subtypeResRef != "" && subtypeResRef != nullValue { subtypeKey := canonicalModelKey("subtype_models", subtypeResRef) property["subtype"] = map[string]any{"ref": subtypeKey} if _, ok := subtypeRegistryByKey[subtypeKey]; !ok { subtypeRegistryByKey[subtypeKey] = buildLegacySubtypeModel(subtypeKey, subtypeResRef, ownedSubtypeTables) } } costValue := stringField(row, "Cost") costID := stringField(row, "CostTableResRef") if costValue != "" && costValue != nullValue || costID != "" && costID != nullValue { cost := map[string]any{} if costValue != "" && costValue != nullValue { cost["value"] = costValue } if costID != "" && costID != nullValue { id, err := strconv.Atoi(costID) if err != nil { return 0, err } costRow, ok := costByID[id] if !ok { return 0, fmt.Errorf("%s references missing cost table index %s", key, costID) } cost["ref"] = canonicalModelKey("cost_models", stringField(costRow, "Name")) } property["cost"] = cost } if paramID := stringField(row, "Param1ResRef"); paramID != "" && paramID != nullValue { id, err := strconv.Atoi(paramID) if err != nil { return 0, err } paramRow, ok := paramByID[id] if !ok { return 0, fmt.Errorf("%s references missing param table index %s", key, paramID) } property["param"] = map[string]any{"ref": canonicalModelKey("param_models", stringField(paramRow, "TableResRef"))} } if legacyTLK != nil { property["name"] = importLegacyStrrefValue(property["name"], key+".name", legacyTLK, legacyKeyByID) property["property_text"] = importLegacyStrrefValue(property["property_text"], key+".property_text", legacyTLK, legacyKeyByID) if description, ok := property["description"]; ok { property["description"] = importLegacyStrrefValue(description, key+".description", legacyTLK, legacyKeyByID) } if updated, _, err := inlineLegacyTLKValue(property, legacyTLK); err != nil { return 0, err } else if updated { // property updated in place } } properties = append(properties, property) } costModels := make([]map[string]any, 0, len(costIndex.Rows)) for _, row := range costIndex.Rows { key := canonicalModelKey("cost_models", stringField(row, "Name")) registryLock[key] = row["id"].(int) model := map[string]any{ "key": key, "index_name": row["Name"], "label": row["Label"], "client_load": row["ClientLoad"], "table": buildLegacyOwnedModelTable(stringField(row, "Name"), ownedCostTables), } costModels = append(costModels, model) } paramModels := make([]map[string]any, 0, len(paramIndex.Rows)) for _, row := range paramIndex.Rows { key := canonicalModelKey("param_models", stringField(row, "TableResRef")) registryLock[key] = row["id"].(int) model := map[string]any{ "key": key, "name": deepCopyValue(row["Name"]), "label": row["Lable"], "table": buildLegacyOwnedModelTable(stringField(row, "TableResRef"), ownedParamTables), } paramModels = append(paramModels, model) } subtypeModels := make([]map[string]any, 0, len(subtypeRegistryByKey)) for _, key := range sortedKeysAny(subtypeRegistryByKey) { subtypeModels = append(subtypeModels, subtypeRegistryByKey[key]) } writes := []struct { path string obj map[string]any }{ {filepath.Join(registryDir, "properties.json"), map[string]any{"rows": properties}}, {filepath.Join(registryDir, "cost_models.json"), map[string]any{"rows": costModels}}, {filepath.Join(registryDir, "param_models.json"), map[string]any{"rows": paramModels}}, {filepath.Join(registryDir, "subtype_models.json"), map[string]any{"rows": subtypeModels}}, {filepath.Join(registryDir, itempropsRegistryLock), anyMapInt(registryLock)}, } if err := os.MkdirAll(registryDir, 0o755); err != nil { return 0, err } for _, write := range writes { raw, err := json.MarshalIndent(write.obj, "", " ") if err != nil { return 0, err } raw = append(raw, '\n') if err := os.WriteFile(write.path, raw, 0o644); err != nil { return 0, err } } return len(writes), nil } func countLegacyTLKRefsInRegistry(registryDir string) (int, error) { paths, err := collectDataJSONPaths(registryDir) if err != nil { return 0, err } refs := 0 for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { return 0, err } refs += countLegacyTLKRefsInValue(obj) } return refs, nil } func collectLegacyOwnedItempropTables(root, skipDir string) (map[string]map[string]any, error) { result := map[string]map[string]any{} entries, err := os.ReadDir(root) if err != nil { return nil, err } for _, entry := range entries { if !entry.IsDir() { continue } if skipDir != "" && entry.Name() == skipDir { continue } dir := filepath.Join(root, entry.Name()) basePath := filepath.Join(dir, "base.json") if _, err := os.Stat(basePath); err != nil { continue } collected, err := collectBaseDataset(nativeDataset{ Name: filepath.ToSlash(filepath.Join("itemprops", filepath.Base(root), entry.Name())), BasePath: basePath, LockPath: filepath.Join(dir, "lock.json"), ModulesDir: filepath.Join(dir, "modules"), Spec: specForDataset("itemprops"), }) if err != nil { return nil, err } result[strings.ToLower(outputStem(collected.Dataset.OutputName))] = map[string]any{ "ownership": "owned", "resref": outputStem(collected.Dataset.OutputName), "output": collected.Dataset.OutputName, "columns": stringSliceToAny(collected.Columns), "rows": rowsToAny(collected.Rows), } } return result, nil } func buildLegacySubtypeModel(key, resref string, owned map[string]map[string]any) map[string]any { return map[string]any{ "key": key, "table_resref": resref, "table": buildLegacyOwnedModelTable(resref, owned), } } func buildLegacyOwnedModelTable(resref string, owned map[string]map[string]any) map[string]any { if table, ok := owned[strings.ToLower(resref)]; ok { return table } return map[string]any{ "ownership": "external", "resref": resref, } } func canonicalItempropPropertyKey(row map[string]any) string { if key := stringField(row, "key"); key != "" { return "itemprop:" + strings.TrimPrefix(key, "itempropdef:") } return canonicalModelKey("itemprop", stringField(row, "Label")) } func canonicalModelKey(prefix, source string) string { normalized := strings.ToLower(strings.TrimSpace(source)) normalized = strings.ReplaceAll(normalized, " ", "") normalized = strings.ReplaceAll(normalized, "-", "") normalized = strings.ReplaceAll(normalized, "_", "") return prefix + ":" + normalized } func importLegacyStrrefValue(value any, fallbackKey string, legacy *legacyTLKData, keyByID map[string]string) any { if legacy == nil { return value } if _, ok, _ := parseTLKPayload(value, true); ok { return value } _ = keyByID key := fallbackKey entry, ok := legacy.Entries[key] if !ok { return value } payload := map[string]any{ "key": key, "text": entry.Text, } if entry.SoundResRef != "" { payload["sound_resref"] = entry.SoundResRef } if entry.SoundLength != 0 { payload["sound_length"] = entry.SoundLength } return map[string]any{"tlk": payload} } func importLegacyAvailability(row map[string]any) []any { out := make([]any, 0) for _, column := range itempropsAvailabilityColumns { value, ok := row[column] if !ok { continue } text := formatRegistryScalar(value) if text != "1" { continue } out = append(out, normalizeAvailabilityName(column)) } return out } func anyMapInt(values map[string]int) map[string]any { out := make(map[string]any, len(values)) for key, value := range values { out[key] = value } return out } func stringSliceToAny(values []string) []any { out := make([]any, 0, len(values)) for _, value := range values { out = append(out, value) } return out } func rowsToAny(rows []map[string]any) []any { out := make([]any, 0, len(rows)) for _, row := range rows { out = append(out, deepCopyValue(row)) } return out } func sortedKeysAny(values map[string]map[string]any) []string { out := make([]string, 0, len(values)) for key := range values { out = append(out, key) } slices.Sort(out) return out } func validateItempropsRegistryGraph(dataDir string, report *ValidationReport) { registry, err := loadItempropsRegistry(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)), Message: err.Error(), }) return } if registry == nil { return } seenPropertyKeys := map[string]struct{}{} seenDefLabels := map[string]string{} seenSetLabels := map[string]string{} seenCostKeys := map[string]struct{}{} seenParamKeys := map[string]struct{}{} seenSubtypeKeys := map[string]string{} ownedOutputs := map[string]string{} for _, row := range registry.CostModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "cost_models.json"), Message: "cost model row is missing key", }) continue } if _, ok := seenCostKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "cost_models.json"), Message: fmt.Sprintf("duplicate cost model key %q", key), }) } seenCostKeys[key] = struct{}{} validateOwnedRegistryTable(filepath.Join(registry.RootPath, "cost_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.ParamModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "param_models.json"), Message: "param model row is missing key", }) continue } if _, ok := seenParamKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "param_models.json"), Message: fmt.Sprintf("duplicate param model key %q", key), }) } seenParamKeys[key] = struct{}{} validateOwnedRegistryTable(filepath.Join(registry.RootPath, "param_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.SubtypeModels { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "subtype_models.json"), Message: "subtype model row is missing key", }) continue } resref := stringField(row, "table_resref") if existing, ok := seenSubtypeKeys[key]; ok { if !strings.EqualFold(existing, resref) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "subtype_models.json"), Message: fmt.Sprintf("duplicate subtype model key %q", key), }) } continue } seenSubtypeKeys[key] = resref validateOwnedRegistryTable(filepath.Join(registry.RootPath, "subtype_models.json"), key, row, ownedOutputs, report) } for _, row := range registry.Properties { key := stringField(row, "key") if key == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: "property row is missing key", }) continue } if _, ok := seenPropertyKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("duplicate property key %q", key), }) } seenPropertyKeys[key] = struct{}{} defLabel := itempropsDefLabel(row) if defLabel == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s is missing label", key), }) } else if owner, ok := seenDefLabels[strings.ToLower(defLabel)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("generated itempropdef label collision between %q and %q", owner, key), }) } else { seenDefLabels[strings.ToLower(defLabel)] = key } setLabel := itempropsSetLabel(row) if setLabel == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s is missing itemprops set label", key), }) } else if owner, ok := seenSetLabels[strings.ToLower(setLabel)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("generated itemprops set label collision between %q and %q", owner, key), }) } else { seenSetLabels[strings.ToLower(setLabel)] = key } if _, err := canonicalAvailability(row); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: %v", key, err), }) } if subtypeKey, ok := refField(row["subtype"]); ok { if _, exists := seenSubtypeKeys[subtypeKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown subtype ref %s", key, subtypeKey), }) } } if rawCost, ok := row["cost"].(map[string]any); ok { if costKey, ok := refField(rawCost); ok { if _, exists := seenCostKeys[costKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown cost ref %s", key, costKey), }) } } } if paramKey, ok := refField(row["param"]); ok { if _, exists := seenParamKeys[paramKey]; !exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(registry.RootPath, "properties.json"), Message: fmt.Sprintf("%s: unknown param ref %s", key, paramKey), }) } } } } func validateOwnedRegistryTable(path, key string, row map[string]any, ownedOutputs map[string]string, report *ValidationReport) { table, ok := row["table"].(map[string]any) if !ok { return } if !strings.EqualFold(stringField(table, "ownership"), "owned") { return } resref := stringField(table, "resref") if resref == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table is missing resref", key), }) return } outputName := stringField(table, "output") if outputName == "" { outputName = resref + ".2da" } if owner, ok := ownedOutputs[strings.ToLower(outputName)]; ok && owner != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("owned table output collision between %q and %q", owner, key), }) } else { ownedOutputs[strings.ToLower(outputName)] = key } if _, ok := table["columns"].([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table columns must be an array", key), }) } if _, ok := table["rows"].([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s: owned table rows must be an array", key), }) } }