From c5a18df7305850a6206cca3b4ca5f15a3c9eb7bf Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 26 May 2026 16:11:45 +0200 Subject: [PATCH] Sidecar generation --- README.md | 3 +- internal/project/project.go | 2 +- internal/topdata/generated_assets.go | 2 +- internal/topdata/native.go | 258 +++++++++++++++++++++++++-- internal/topdata/topdata_test.go | 160 +++++++++++++++++ 5 files changed, 405 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9ab57fc..a449ecb 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ printing the secret. Topdata value encodings under `topdata.value_encodings` are matched by dataset and column. Set `dataset: "*"` to apply an encoding to the same column name in any native topdata dataset; an exact dataset rule takes precedence over the -wildcard rule for that column. +wildcard rule for that column. Supported compact encodings include packed hex +lists, external hex-list sidecar tables, alignment set masks, and ID hex lists. Common configurable defaults: diff --git a/internal/project/project.go b/internal/project/project.go index 53135a3..1070798 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -797,7 +797,7 @@ func validateTopDataValueEncodings(encodings []TopDataValueEncodingConfig) []err failures = append(failures, fmt.Errorf("%s.column is required", prefix)) } switch strings.TrimSpace(encoding.Mode) { - case "packed_hex_list", "alignment_hex_list", "id_hex_list": + case "packed_hex_list", "external_hex_list", "alignment_hex_list", "alignment_set_mask", "id_hex_list": default: failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode)) } diff --git a/internal/topdata/generated_assets.go b/internal/topdata/generated_assets.go index e0591ab..de7ceec 100644 --- a/internal/topdata/generated_assets.go +++ b/internal/topdata/generated_assets.go @@ -99,7 +99,7 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat results := make([]Generated2DAAsset, 0, len(collected)) for _, dataset := range collected { - compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}) + compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, nil) if err != nil { return nil, err } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index b08af68..df03ddb 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -71,6 +71,144 @@ type resolvedTableRegistry struct { tableByKey map[string]resolvedTable } +type nativeSidecarCollector struct { + tables map[string][]map[string]any +} + +func newNativeSidecarCollector() *nativeSidecarCollector { + return &nativeSidecarCollector{tables: map[string][]map[string]any{}} +} + +func (c *nativeSidecarCollector) addExternalHexList(dataset nativeDataset, row map[string]any, field string, values []int, encoding project.TopDataValueEncodingConfig) (string, error) { + if c == nil { + return "", fmt.Errorf("external hex list sidecar collector is not available") + } + ref := sidecarRowReference(dataset.Name, row) + if ref == "" { + return "", fmt.Errorf("field %s: external hex list requires row id or key", field) + } + outputName := sidecarOutputName(dataset.OutputName, field) + rows := c.tables[outputName] + for _, value := range values { + parsed, err := parseConfiguredListValue(value, encoding) + if err != nil { + return "", err + } + rows = append(rows, map[string]any{ + "id": len(rows), + "Label": ref, + "Value": "0x" + fmt.Sprintf("%0*X", encoding.HexWidth, parsed), + }) + } + c.tables[outputName] = rows + return ref, nil +} + +func (c *nativeSidecarCollector) writeAll(outputDir string) error { + if c == nil || len(c.tables) == 0 { + return nil + } + for _, outputName := range sortedStringMapKeys(c.tables) { + data := map[string]any{ + "columns": []string{"Label", "Value"}, + "rows": c.tables[outputName], + } + if err := write2DA(data, filepath.Join(outputDir, outputName), false, -1); err != nil { + return err + } + } + return nil +} + +func sidecarOutputName(parentOutputName, field string) string { + parent := normalizeSidecarToken(outputStem(parentOutputName)) + acronym := sidecarTokenAcronym(field) + if acronym != "" { + candidate := parent + "_" + acronym + if len(candidate) <= 16 { + return candidate + ".2da" + } + } + stem := normalizeSidecarToken(parent + "_" + field) + if len(stem) <= 16 { + return stem + ".2da" + } + hash := stableShortHash(stem) + prefixLen := 16 - len(hash) - 1 + if prefixLen < 1 { + prefixLen = 1 + } + return stem[:prefixLen] + "_" + hash + ".2da" +} + +func sidecarTokenAcronym(value string) string { + var builder strings.Builder + previousWasLowerOrDigit := false + for _, r := range strings.TrimSpace(value) { + if r >= 'A' && r <= 'Z' { + if builder.Len() == 0 || previousWasLowerOrDigit { + builder.WriteByte(byte(r + ('a' - 'A'))) + } + previousWasLowerOrDigit = false + continue + } + if r >= 'a' && r <= 'z' { + if builder.Len() == 0 { + builder.WriteRune(r) + } + previousWasLowerOrDigit = true + continue + } + if r >= '0' && r <= '9' { + builder.WriteRune(r) + previousWasLowerOrDigit = true + continue + } + previousWasLowerOrDigit = false + } + return builder.String() +} + +func sidecarRowReference(datasetName string, row map[string]any) string { + if key, ok := row["key"].(string); ok && key != "" { + if index := strings.LastIndex(key, ":"); index >= 0 && index < len(key)-1 { + return normalizeSidecarToken(key[index+1:]) + } + return normalizeSidecarToken(key) + } + if id, ok := row["id"].(int); ok { + return fmt.Sprintf("%s_%d", normalizeSidecarToken(datasetName), id) + } + return "" +} + +func normalizeSidecarToken(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + previousUnderscore := false + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + previousUnderscore = false + continue + } + if !previousUnderscore { + builder.WriteByte('_') + previousUnderscore = true + } + } + return strings.Trim(builder.String(), "_") +} + +func stableShortHash(value string) string { + var hash uint32 = 2166136261 + for _, b := range []byte(value) { + hash ^= uint32(b) + hash *= 16777619 + } + return fmt.Sprintf("%04x", hash&0xffff) +} + func newResolvedTableRegistry(collected []nativeCollectedDataset) (resolvedTableRegistry, error) { registry := resolvedTableRegistry{ stemByKey: map[string]string{}, @@ -356,6 +494,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress groupStats := nativeCompileGroupStats(collected) currentGroup := "" + sidecars := newNativeSidecarCollector() for _, dataset := range collected { group := nativeCompileGroup(dataset.Dataset.Name) if group != currentGroup { @@ -368,7 +507,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress stats.SourceFragments, )) } - compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections) + compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars) if err != nil { return BuildResult{}, err } @@ -376,6 +515,9 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress return BuildResult{}, err } } + if err := sidecars.writeAll(output2DA); err != nil { + return BuildResult{}, err + } filesTLK, err := compiler.finish(outputTLK, filepath.Base(p.TopDataCompiledTLKPath())) if err != nil { @@ -3181,7 +3323,7 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) }, nil } -func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig) (map[string]any, error) { +func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (map[string]any, error) { rows := dataset.Rows if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") { classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:] @@ -3201,6 +3343,7 @@ func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int rowByKey: globalRowByKey, tableRegistry: tableRegistry, compiler: compiler, + sidecars: sidecars, cache: map[string]map[string]tlkCompiledValue{}, } @@ -3818,6 +3961,7 @@ type valueResolver struct { rowByKey map[string]map[string]any tableRegistry resolvedTableRegistry compiler *tlkCompiler + sidecars *nativeSidecarCollector cache map[string]map[string]tlkCompiledValue } @@ -3901,12 +4045,21 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any } else if ok { return r.resolveTLKPayload(row, field, payload, stack) } - if encoding, ok := r.valueEncodingForField(field); ok && strings.TrimSpace(encoding.Mode) == "alignment_hex_list" { - encoded, err := encodeConfiguredAlignmentHexList(value, encoding) - if err != nil { - return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) + if encoding, ok := r.valueEncodingForField(field); ok { + switch strings.TrimSpace(encoding.Mode) { + case "alignment_hex_list": + encoded, err := encodeConfiguredAlignmentHexList(value, encoding) + if err != nil { + return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) + } + return tlkCompiledValue{Value: encoded}, nil + case "alignment_set_mask": + encoded, err := encodeConfiguredAlignmentSetMask(value, encoding) + if err != nil { + return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) + } + return tlkCompiledValue{Value: encoded}, nil } - return tlkCompiledValue{Value: encoded}, nil } switch typed := value.(type) { @@ -3916,8 +4069,8 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any _, hasList := typed["list"] _, hasAllExcept := typed["all_except"] _, hasIDs := typed["ids"] - if mode == "packed_hex_list" && (hasList || hasAllExcept) { - value, err := encodeConfiguredValueList(typed, encoding) + if (mode == "packed_hex_list" || mode == "external_hex_list") && (hasList || hasAllExcept) { + value, err := r.encodeConfiguredValueList(row, field, typed, encoding) if err != nil { return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) } @@ -4003,40 +4156,63 @@ func (r *valueResolver) valueDefaultForField(field string) (any, bool) { return value, ok } +func (r *valueResolver) encodeConfiguredValueList(row map[string]any, field string, obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) { + mode := strings.TrimSpace(encoding.Mode) + if mode != "packed_hex_list" && mode != "external_hex_list" { + return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) + } + values, err := expandConfiguredValueListValues(obj, encoding) + if err != nil { + return "", err + } + if mode == "external_hex_list" { + return r.sidecars.addExternalHexList(r.dataset, row, field, values, encoding) + } + return formatConfiguredPackedHexList(values, encoding), nil +} + func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) { if strings.TrimSpace(encoding.Mode) != "packed_hex_list" { return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) } + values, err := expandConfiguredValueListValues(obj, encoding) + if err != nil { + return "", err + } + return formatConfiguredPackedHexList(values, encoding), nil +} + +func expandConfiguredValueListValues(obj map[string]any, encoding project.TopDataValueEncodingConfig) ([]int, error) { _, hasList := obj["list"] _, hasAllExcept := obj["all_except"] if hasList && hasAllExcept { - return "", fmt.Errorf("list encoding object cannot contain both list and all_except") + return nil, fmt.Errorf("list encoding object cannot contain both list and all_except") } for key := range obj { if key != "list" && key != "all_except" { - return "", fmt.Errorf("list encoding object contains unsupported key %q", key) + return nil, fmt.Errorf("list encoding object contains unsupported key %q", key) } } if hasAllExcept { values, err := expandConfiguredValueAllExcept(obj["all_except"], encoding) if err != nil { - return "", err + return nil, err } - return formatConfiguredPackedHexList(values, encoding), nil + return values, nil } rawList, ok := obj["list"].([]any) if !ok { - return "", fmt.Errorf("list must be an array") + return nil, fmt.Errorf("list must be an array") } values := make([]int, 0, len(rawList)) for _, raw := range rawList { expanded, err := expandConfiguredValueListItem(raw, encoding) if err != nil { - return "", err + return nil, err } values = append(values, expanded...) } - return formatConfiguredPackedHexList(values, encoding), nil + return values, nil } func encodeConfiguredAlignmentHexList(raw any, encoding project.TopDataValueEncodingConfig) (string, error) { @@ -4047,8 +4223,31 @@ func encodeConfiguredAlignmentHexList(raw any, encoding project.TopDataValueEnco return formatConfiguredPackedHexList(values, encoding), nil } +func encodeConfiguredAlignmentSetMask(raw any, encoding project.TopDataValueEncodingConfig) (string, error) { + values, err := expandConfiguredAlignmentList(raw) + if err != nil { + return "", err + } + mask := 0 + for _, value := range values { + if value == 0 { + continue + } + bit, ok := configuredAlignmentSetBit(value) + if !ok { + return "", fmt.Errorf("alignment value 0x%02X is not a supported exact alignment", value) + } + mask |= bit + } + if _, err := parseConfiguredListValue(mask, encoding); err != nil { + return "", err + } + return "0x" + fmt.Sprintf("%0*X", encoding.HexWidth, mask), nil +} + func (r *valueResolver) encodeConfiguredIDHexList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) { - if strings.TrimSpace(encoding.Mode) != "id_hex_list" { + mode := strings.TrimSpace(encoding.Mode) + if mode != "id_hex_list" { return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) } for key := range obj { @@ -4232,6 +4431,31 @@ func configuredAlignmentAxisValue(letter byte, lawChaos bool) (int, bool) { } } +func configuredAlignmentSetBit(value int) (int, bool) { + switch value { + case 0x0A: // lawful good + return 1 << 0, true + case 0x03: // lawful neutral + return 1 << 1, true + case 0x12: // lawful evil + return 1 << 2, true + case 0x09: // neutral good + return 1 << 3, true + case 0x01: // true neutral + return 1 << 4, true + case 0x11: // neutral evil + return 1 << 5, true + case 0x0C: // chaotic good + return 1 << 6, true + case 0x05: // chaotic neutral + return 1 << 7, true + case 0x14: // chaotic evil + return 1 << 8, true + default: + return 0, false + } +} + func formatConfiguredPackedHexList(values []int, encoding project.TopDataValueEncodingConfig) string { if len(values) == 0 { return "" diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 4f3b91a..12ab4ca 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -290,6 +290,62 @@ func TestBuildNativeEncodesConfiguredAlignmentHexLists(t *testing.T) { } } +func TestBuildNativeEncodesConfiguredAlignmentSetMasks(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{ + "output": "classes.2da", + "columns": ["Label", "PreferredAlignments"], + "rows": [ + {"id": 0, "key": "classes:barbarian", "Label": "Barbarian"}, + {"id": 1, "key": "classes:paladin", "Label": "Paladin"}, + {"id": 2, "key": "classes:fighter", "Label": "Fighter"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{"classes:barbarian":0,"classes:paladin":1,"classes:fighter":2}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules", "10_alignments.json"), `{ + "overrides": [ + { + "key": "classes:barbarian", + "PreferredAlignments": {"alignments": ["ng", "cg", "tn", "cn", "ne", "ce"]} + }, + { + "key": "classes:paladin", + "PreferredAlignments": {"alignments": ["lg"]} + }, + { + "key": "classes:fighter", + "PreferredAlignments": "0x00" + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "*", Column: "PreferredAlignments", Mode: "alignment_set_mask", Min: 0, Max: 511, HexWidth: 3}, + } + + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da")) + if err != nil { + t.Fatalf("read classes.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "0\tBarbarian\t0x1F8\n", + "1\tPaladin\t0x001\n", + "2\tFighter\t0x000\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected compiled classes.2da to contain %q, got:\n%s", want, got) + } + } +} + func TestBuildNativeEncodesConfiguredIDHexLists(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) @@ -348,6 +404,109 @@ func TestBuildNativeEncodesConfiguredIDHexLists(t *testing.T) { } } +func TestBuildNativeEncodesConfiguredExternalHexListsAndPackedIDs(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL"], + "rows": [ + {"id": 1, "key": "feat:first", "LABEL": "First"}, + {"id": 2, "key": "feat:second", "LABEL": "Second"}, + {"id": 3, "key": "feat:third", "LABEL": "Third"}, + {"id": 7, "key": "feat:seventh", "LABEL": "Seventh"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:first": 1, + "feat:second": 2, + "feat:third": 3, + "feat:seventh": 7 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "AvailableSkinColors"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{ + "overrides": [ + { + "key": "racialtypes:human", + "AvailableSkinColors": {"list": [1, 2, 3, 5, {"range": [7, 9]}]} + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{ + "output": "baseitems.2da", + "columns": ["Label", "ProficiencyFeats"], + "rows": [ + {"id": 42, "key": "baseitems:testclub", "Label": "Test Club"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:testclub":42}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_testclub.json"), `{ + "overrides": [ + { + "key": "baseitems:testclub", + "ProficiencyFeats": {"ids": ["feat:first", "feat:second", "feat:third", "feat:seventh"]} + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "external_hex_list", Min: 0, Max: 255, HexWidth: 2}, + {Dataset: "*", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4}, + } + + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + racialtypesRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da")) + if err != nil { + t.Fatalf("read racialtypes.2da: %v", err) + } + if got := string(racialtypesRaw); !strings.Contains(got, "6\tHuman\thuman\n") { + t.Fatalf("expected compiled racialtypes.2da to contain a sidecar reference, got:\n%s", got) + } + sidecarOutputName := sidecarOutputName("racialtypes.2da", "AvailableSkinColors") + if stem := outputStem(sidecarOutputName); len(stem) > 16 { + t.Fatalf("expected generated sidecar stem to fit NWN resource limit, got %q", stem) + } + sidecarRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, sidecarOutputName)) + if err != nil { + t.Fatalf("read %s: %v", sidecarOutputName, err) + } + for _, want := range []string{ + "Label\tValue\n", + "0\thuman\t0x01\n", + "1\thuman\t0x02\n", + "2\thuman\t0x03\n", + "3\thuman\t0x05\n", + "4\thuman\t0x07\n", + "5\thuman\t0x08\n", + "6\thuman\t0x09\n", + } { + if got := string(sidecarRaw); !strings.Contains(got, want) { + t.Fatalf("expected sidecar 2da to contain %q, got:\n%s", want, got) + } + } + baseitemsRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da")) + if err != nil { + t.Fatalf("read baseitems.2da: %v", err) + } + if got := string(baseitemsRaw); !strings.Contains(got, "0x0001000200030007") { + t.Fatalf("expected compiled baseitems.2da to contain packed IDs, got:\n%s", got) + } +} + func TestBuildNativeAppliesWildcardConfiguredValueEncodings(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) @@ -1118,6 +1277,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T) tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, + nil, ) if err != nil { t.Fatalf("resolveNativeDataset failed: %v", err)