package topdata

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)

func TestValidateProjectAcceptsInlineTLKSchema(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "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", "FEAT", "DESCRIPTION"],
  "rows": [
    {
      "id": 0,
      "key": "feat:test",
      "LABEL": "FEAT_TEST",
      "_tlk": {
        "name": "Test Feat",
        "description": "Test Description"
      }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected no topdata validation errors, got %#v", report.Diagnostics)
	}
}

func TestValidateAndBuildDerivesFeatOutputWhenOmitted(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "columns": ["LABEL", "FEAT", "DESCRIPTION"],
  "rows": [
    {
      "id": 0,
      "key": "feat:test",
      "LABEL": "FEAT_TEST",
      "FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
      "DESCRIPTION": {"tlk": {"key": "feat:test.description", "text": "Test Description"}}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")

	proj := testProject(root)
	report := ValidateProject(proj)
	if report.HasErrors() {
		t.Fatalf("expected omitted feat output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
	}

	result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
	if err != nil {
		t.Fatalf("expected omitted feat output to build: %v", err)
	}
	if _, err := os.Stat(filepath.Join(result.Output2DADir, "feat.2da")); err != nil {
		t.Fatalf("expected derived feat.2da output: %v", err)
	}
}

func TestValidateProjectRejectsInvalidFeatInvariantContract(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION"],
  "rows": [
    {
      "id": 0,
      "key": "masterfeats:test",
      "LABEL": "FEAT_TEST",
      "FEAT": "100",
      "DESCRIPTION": "200"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"masterfeats:test":0}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected validation errors for invalid feat invariants")
	}
	text := diagnosticsText(report.Diagnostics)
	for _, want := range []string{
		"feat output must be feat.2da",
		`feat row 0 key "masterfeats:test" must start with feat:`,
		`feat lock key "masterfeats:test" must start with feat:`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected validation message %q, got:\n%s", want, text)
		}
	}
}

func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "output": "other.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected duplicate JSON key validation error")
	}
	if !diagnosticsContain(report.Diagnostics, "duplicate JSON object key") {
		t.Fatalf("expected duplicate JSON object key diagnostic, got %#v", report.Diagnostics)
	}
}

func TestValidateProjectRejectsDuplicateEntryKeysAcrossModules(t *testing.T) {
	root := testProjectRoot(t)
	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", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["Label", "MaxRange"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_add_sign.json"), `{
  "entries": {
    "baseitems:holdable_sign": {"Label": "Holdable Sign", "MaxRange": "100"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "20_add_sign_again.json"), `{
  "entries": {
    "baseitems:holdable_sign": {"Label": "Duplicate Sign", "MaxRange": "200"}
  }
}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected duplicate entry key validation error")
	}
	text := diagnosticsText(report.Diagnostics)
	if !strings.Contains(text, `duplicate entries key "baseitems:holdable_sign"`) ||
		!strings.Contains(text, "10_add_sign.json") ||
		!strings.Contains(text, "20_add_sign_again.json") {
		t.Fatalf("expected duplicate entry key diagnostic with both module paths, got:\n%s", text)
	}
}

func TestResolvedTableRegistryRegistersConsistentTables(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Kind:       nativeDatasetPlain,
				Name:       "classes/savthr/fortitude_high",
				OutputName: "cls_savthr_fort.2da",
			},
			Columns: []string{"Level", "FortSave"},
			Rows: []map[string]any{
				{"id": 1, "Level": "1", "FortSave": "2"},
			},
			LockData: map[string]int{},
			TableKey: "classes/saves:fortitude",
		},
		{
			Dataset: nativeDataset{
				Kind:       nativeDatasetPlain,
				Name:       "classes/bfeat/fighter",
				OutputName: "cls_bfeat_fight.2da",
			},
			Columns: []string{"Bonus"},
			Rows: []map[string]any{
				{"id": 1, "Bonus": "1"},
			},
			LockData: map[string]int{"classes:dummy": 99},
			TableKey: "classes/bonusfeats:fighter",
		},
	}

	registry, err := newResolvedTableRegistry(collected)
	if err != nil {
		t.Fatalf("newResolvedTableRegistry failed: %v", err)
	}

	saveTable, ok := registry.resolveTableByKey("classes/saves:fortitude")
	if !ok {
		t.Fatal("expected save table to resolve")
	}
	if saveTable.Key != "classes/saves:fortitude" ||
		saveTable.DatasetName != "classes/savthr/fortitude_high" ||
		saveTable.OutputName != "cls_savthr_fort.2da" ||
		saveTable.OutputStem != "cls_savthr_fort" ||
		saveTable.Kind != nativeDatasetPlain {
		t.Fatalf("unexpected save table metadata: %#v", saveTable)
	}
	if len(saveTable.Columns) != 2 || len(saveTable.Rows) != 1 {
		t.Fatalf("unexpected save table shape: %#v", saveTable)
	}

	collected[0].Rows[0]["FortSave"] = "99"
	if got := saveTable.Rows[0]["FortSave"]; got != "2" {
		t.Fatalf("expected registry table rows to be cloned, got %v", got)
	}

	bonusTable, ok := registry.resolveTableByKey("classes/bonusfeats:fighter")
	if !ok {
		t.Fatal("expected bonus feat table to resolve")
	}
	if bonusTable.OutputStem != "cls_bfeat_fight" || bonusTable.LockData["classes:dummy"] != 99 {
		t.Fatalf("unexpected bonus feat table: %#v", bonusTable)
	}
}

func TestSaveLockfilePreservesExistingOrderAndAppendsNewKeys(t *testing.T) {
	root := t.TempDir()
	lockPath := filepath.Join(root, "lock.json")
	writeFile(t, lockPath, "{\n  \"b\": 2,\n  \"a\": 1\n}\n")

	if err := saveLockfile(lockPath, map[string]int{
		"b": 2,
		"a": 1,
		"c": 3,
	}); err != nil {
		t.Fatalf("saveLockfile failed: %v", err)
	}

	got, err := os.ReadFile(lockPath)
	if err != nil {
		t.Fatalf("read lockfile: %v", err)
	}
	want := "{\n  \"b\": 2,\n  \"a\": 1,\n  \"c\": 3\n}\n"
	if string(got) != want {
		t.Fatalf("unexpected lockfile contents:\n%s", string(got))
	}
}

func TestResolvedTableRegistryRejectsDuplicateKeys(t *testing.T) {
	_, err := newResolvedTableRegistry([]nativeCollectedDataset{
		{
			Dataset:  nativeDataset{Name: "classes/skills/barbarian", OutputName: "cls_skill_barb.2da"},
			Columns:  []string{"SkillLabel"},
			Rows:     []map[string]any{{"id": 0, "SkillLabel": "Athletics"}},
			TableKey: "classes/skills:barbarian",
		},
		{
			Dataset:  nativeDataset{Name: "classes/skills/barbarian_alt", OutputName: "cls_skill_barb_alt.2da"},
			Columns:  []string{"SkillLabel"},
			Rows:     []map[string]any{{"id": 0, "SkillLabel": "Ride"}},
			TableKey: "classes/skills:barbarian",
		},
	})
	if err == nil || !strings.Contains(err.Error(), `duplicate table key "classes/skills:barbarian"`) {
		t.Fatalf("expected duplicate table key error, got %v", err)
	}
}

func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T) {
	tableRegistry, err := newResolvedTableRegistry([]nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Kind:       nativeDatasetPlain,
				Name:       "classes/skills/barbarian",
				OutputName: "cls_skill_barb.2da",
			},
			Columns: []string{"SkillLabel", "SkillIndex", "ClassSkill"},
			Rows: []map[string]any{
				{"id": 0, "SkillLabel": "Athletics", "SkillIndex": 47, "ClassSkill": "1"},
			},
			TableKey: "classes/skills:barbarian",
		},
	})
	if err != nil {
		t.Fatalf("newResolvedTableRegistry failed: %v", err)
	}

	dataset := nativeCollectedDataset{
		Dataset: nativeDataset{
			Kind:       nativeDatasetBase,
			Name:       "classes",
			OutputName: "classes.2da",
			Columns:    []string{"Label", "SkillsTable"},
		},
		Columns: []string{"Label", "SkillsTable"},
		Rows: []map[string]any{
			{
				"id":          0,
				"key":         "classes:barbarian",
				"Label":       "Barbarian",
				"SkillsTable": map[string]any{"table": "classes/skills:barbarian"},
			},
		},
	}

	resolved, err := resolveNativeDataset(
		dataset,
		map[string]int{"classes:barbarian": 0},
		map[string]map[string]any{"classes:barbarian": dataset.Rows[0]},
		tableRegistry,
		nil,
	)
	if err != nil {
		t.Fatalf("resolveNativeDataset failed: %v", err)
	}
	rows := resolved["rows"].([]map[string]any)
	if got := rows[0]["SkillsTable"]; got != "cls_skill_barb" {
		t.Fatalf("expected scalar table reference to resolve to output stem, got %v", got)
	}
}

func TestNormalizeProjectImportsLegacyClasses(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	referenceRoot := filepath.Join(root, "reference")
	mkdirAll(t, filepath.Join(referenceRoot, "data", "classes", "core", "modules"))
	mkdirAll(t, filepath.Join(referenceRoot, "data", "classes", "feats"))
	mkdirAll(t, filepath.Join(referenceRoot, "data", "classes", "skills"))
	mkdirAll(t, filepath.Join(referenceRoot, "data", "classes", "savthr"))
	mkdirAll(t, filepath.Join(referenceRoot, "data", "classes", "bfeat"))
	mkdirAll(t, filepath.Join(referenceRoot, "tlk", "modules", "classes"))

	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "core", "base.json"), `{
  "output": "classes.2da",
  "columns": ["Label", "Description", "FeatsTable", "SavingThrowTable", "SkillsTable", "BonusFeatsTable", "PreReqTable"],
  "rows": [
    {
      "id": 4,
      "key": "classes:fighter",
      "Label": "Fighter",
      "Description": "240",
      "FeatsTable": "cls_feat_fight",
      "SavingThrowTable": "cls_savthr_fort",
      "SkillsTable": "cls_skill_fight",
      "BonusFeatsTable": "cls_bfeat_fight",
      "PreReqTable": "****"
    },
    {
      "id": 11,
      "key": "classes:aberration",
      "Label": "Aberration",
      "Description": "241",
      "FeatsTable": "CLS_FEAT_ABER",
      "SavingThrowTable": "CLS_SAVTHR_WIZ",
      "SkillsTable": "CLS_SKILL_FIGHT",
      "BonusFeatsTable": "CLS_BFEAT_BARB",
      "PreReqTable": "****"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "core", "lock.json"), `{
  "classes:fighter": 4,
  "classes:aberration": 11
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "core", "modules", "fighter.json"), `{
  "overrides": [
    {
      "key": "classes:fighter",
      "Description": { "tlk": "classes:fighter.description" }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "feats", "fighter.json"), `{
  "key": "classes/feats:fighter",
  "output": "cls_feat_fight.2da",
  "columns": ["FeatIndex"],
  "rows": [
    {"FeatIndex": {"id": "feat:literate"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "skills", "fighter.json"), `{
  "key": "classes/skills:fighter",
  "output": "cls_skill_fight.2da",
  "columns": ["SkillIndex"],
  "rows": [
    {"SkillIndex": {"id": "skills:spot"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "savthr", "fortitude_high.json"), `{
  "key": "classes/saves:fortitude",
  "output": "cls_savthr_fort.2da",
  "columns": ["Level", "FortSave"],
  "rows": [
    {"Level": "1", "FortSave": "2"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "data", "classes", "bfeat", "fighter.json"), `{
  "key": "classes/bonusfeats:fighter",
  "output": "cls_bfeat_fight.2da",
  "columns": ["Bonus"],
  "rows": [
    {"id": 1, "Bonus": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "tlk", "lock.json"), `{
  "classes:fighter.description": 1234
}`+"\n")
	writeFile(t, filepath.Join(referenceRoot, "tlk", "modules", "classes", "fighter.json"), `{
  "entries": {
    "classes:fighter.description": {
      "text": "Martial expert."
    }
  }
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = "reference"
	result, err := NormalizeProject(p)
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles == 0 {
		t.Fatal("expected classes import to write canonical files")
	}

	coreObj := map[string]any{}
	coreRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "classes", "core", "base.json"))
	if err != nil {
		t.Fatalf("read classes core: %v", err)
	}
	if err := json.Unmarshal(coreRaw, &coreObj); err != nil {
		t.Fatalf("parse classes core: %v", err)
	}
	rows := coreObj["rows"].([]any)
	row := rows[0].(map[string]any)

	description := row["Description"].(map[string]any)["tlk"].(map[string]any)
	if description["key"] != "classes:fighter.description" || description["text"] != "Martial expert." {
		t.Fatalf("unexpected inline TLK payload: %#v", description)
	}
	if table := row["FeatsTable"].(map[string]any)["table"]; table != "classes/feats:fighter" {
		t.Fatalf("expected feats table ref rewrite, got %#v", row["FeatsTable"])
	}
	if table := row["SavingThrowTable"].(map[string]any)["table"]; table != "classes/saves:fortitude" {
		t.Fatalf("expected save table ref rewrite, got %#v", row["SavingThrowTable"])
	}
	if table := row["SkillsTable"].(map[string]any)["table"]; table != "classes/skills:fighter" {
		t.Fatalf("expected skills table ref rewrite, got %#v", row["SkillsTable"])
	}
	if table := row["BonusFeatsTable"].(map[string]any)["table"]; table != "classes/bonusfeats:fighter" {
		t.Fatalf("expected bonus feats table ref rewrite, got %#v", row["BonusFeatsTable"])
	}
	monsterRow := rows[1].(map[string]any)
	if got := monsterRow["SkillsTable"]; got != "CLS_SKILL_FIGHT" {
		t.Fatalf("expected legacy uppercase monster table stem to remain literal, got %#v", got)
	}

	state := tlkStateDocument{}
	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", ".tlk_state.json"))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	if err := json.Unmarshal(stateRaw, &state); err != nil {
		t.Fatalf("parse tlk state: %v", err)
	}
	if got := state.Entries["classes:fighter.description"].ID; got != 1234 {
		t.Fatalf("expected preserved TLK state ID 1234, got %d", got)
	}
}

func TestBuildNativeSupportsClassesInlineTLKFields(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core"))
	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", "Short", "Name", "Plural", "Lower", "Description"],
  "rows": [
    {
      "id": 48,
      "key": "classes:swashbuckler",
      "Label": "Swashbuckler",
      "Short": { "tlk": { "key": "classes:swashbuckler.short", "text": "Swa" } },
      "Name": { "tlk": { "key": "classes:swashbuckler.name", "text": "Swashbuckler" } },
      "Plural": { "tlk": { "key": "classes:swashbuckler.plural", "text": "Swashbucklers" } },
      "Lower": { "tlk": { "key": "classes:swashbuckler.lower", "text": "swashbuckler" } },
      "Description": { "tlk": { "key": "classes:swashbuckler.description", "text": "Agile duelist." } }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{
  "classes:swashbuckler": 48
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		report := ValidateProject(p)
		t.Fatalf("BuildNative failed: %v\n%s", err, diagnosticsText(report.Diagnostics))
	}

	classesBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
	if err != nil {
		t.Fatalf("read classes.2da: %v", err)
	}
	classesText := string(classesBytes)
	if !strings.Contains(classesText, "Label\tShort\tName\tPlural\tLower\tDescription\n") {
		t.Fatalf("unexpected classes.2da header:\n%s", classesText)
	}
	if !strings.Contains(classesText, "48\tSwashbuckler\t16777216\t16777217\t16777218\t16777219\t16777220\n") {
		t.Fatalf("expected compiled TLK strrefs in classes.2da, got:\n%s", classesText)
	}
}

func TestBuildNativePreservesClassesLegacyWikiGenerateColumn(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core"))
	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", "WIKIGENERATE", "SkipSpellSelection"],
  "rows": [
    {
      "id": 4,
      "key": "classes:fighter",
      "Label": "Fighter",
      "WIKIGENERATE": "****",
      "SkipSpellSelection": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{
  "classes:fighter": 4
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	report := ValidateProject(p)
	if report.HasErrors() {
		t.Fatalf("expected classes/core legacy WIKIGENERATE compatibility, got %#v", report.Diagnostics)
	}

	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	classesBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
	if err != nil {
		t.Fatalf("read classes.2da: %v", err)
	}
	classesText := string(classesBytes)
	if !strings.Contains(classesText, "Label\tWIKIGENERATE\tSkipSpellSelection\n") {
		t.Fatalf("expected classes.2da to keep WIKIGENERATE column, got:\n%s", classesText)
	}
	if !strings.Contains(classesText, "4\tFighter\t****\t0\n") {
		t.Fatalf("expected classes.2da row to keep WIKIGENERATE value, got:\n%s", classesText)
	}
}

func TestBuildNativeExpandsClassesFeatMasterfeatRows(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "acolyte.json"), `{
  "key": "classes/feats:acolyte",
  "output": "cls_feat_acolyte.2da",
  "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
  "rows": [
    {"FeatIndex": {"id": "feat:literate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"},
    {"FeatIndex": {"id": "masterfeats:spellfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT"],
  "rows": [
    {"id": 35, "key": "feat:spellfocus_abjuration", "LABEL": "SpellFocusAbj", "FEAT": "425", "DESCRIPTION": "426", "MASTERFEAT": "3"},
    {"id": 36, "key": "feat:spellfocus_conjuration", "LABEL": "SpellFocusCon", "FEAT": "166", "DESCRIPTION": "427", "MASTERFEAT": "3"},
    {"id": 1135, "key": "feat:literate", "LABEL": "FEAT_LITERATE", "FEAT": "9000", "DESCRIPTION": "9001", "MASTERFEAT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:spellfocus_abjuration": 35,
  "feat:spellfocus_conjuration": 36,
  "feat:literate": 1135
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {"id": 3, "key": "masterfeats:spellfocus", "LABEL": "SpellFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_skfoc"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:spellfocus": 3
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_acolyte.2da"))
	if err != nil {
		t.Fatalf("read cls_feat_acolyte.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "0\tFEAT_LITERATE\t1135\t3\t1\t0\n") {
		t.Fatalf("expected direct feat row label fill, got:\n%s", text)
	}
	if !strings.Contains(text, "1\tSpellFocusAbj\t35\t1\t-1\t0\n") || !strings.Contains(text, "2\tSpellFocusCon\t36\t1\t-1\t0\n") {
		t.Fatalf("expected masterfeat expansion rows, got:\n%s", text)
	}
}

func TestBuildNativeClassFeatMasterfeatExpansionRespectsAllClassesCanUse(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "base.json"), `{
  "output": "classes.2da",
  "columns": ["Label", "Name", "FeatsTable"],
  "rows": [
    {"id": 4, "key": "classes:fighter", "Label": "Fighter", "Name": "111", "FeatsTable": "cls_feat_fighter"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "lock.json"), `{"classes:fighter":4}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
  "key": "classes/feats:fighter",
  "output": "cls_feat_fighter.2da",
  "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
  "rows": [
    {"FeatIndex": {"id": "masterfeats:weaponfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"},
    {"FeatIndex": {"id": "masterfeats:weaponspecialization"}, "List": "1", "GrantedOnLevel": "4", "OnMenu": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "ALLCLASSESCANUSE", "MinLevelClass"],
  "rows": [
    {"id": 100, "key": "feat:weaponfocus_longsword", "LABEL": "WeaponFocusLongsword", "FEAT": "1000", "DESCRIPTION": "1001", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "1", "MinLevelClass": "****"},
    {"id": 101, "key": "feat:weaponfocus_creature", "LABEL": "WeaponFocusCreature", "FEAT": "1002", "DESCRIPTION": "1003", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "0", "MinLevelClass": "****"},
    {"id": 200, "key": "feat:weaponspecialization_longsword", "LABEL": "WeaponSpecLongsword", "FEAT": "2000", "DESCRIPTION": "2001", "MASTERFEAT": "2", "ALLCLASSESCANUSE": "0", "MinLevelClass": {"id": "classes:fighter"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:weaponfocus_longsword": 100,
  "feat:weaponfocus_creature": 101,
  "feat:weaponspecialization_longsword": 200
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {"id": 1, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"},
    {"id": 2, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "6491", "DESCRIPTION": "437", "ICON": "ife_wepspec"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:weaponfocus": 1,
  "masterfeats:weaponspecialization": 2
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
	if err != nil {
		t.Fatalf("read cls_feat_fighter.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "WeaponFocusLongsword") {
		t.Fatalf("expected globally selectable weapon focus row, got:\n%s", text)
	}
	if strings.Contains(text, "WeaponFocusCreature") {
		t.Fatalf("expected ALLCLASSESCANUSE=0 creature row without class restriction to be filtered, got:\n%s", text)
	}
	if !strings.Contains(text, "WeaponSpecLongsword") {
		t.Fatalf("expected class-restricted ALLCLASSESCANUSE=0 row to remain available to fighter, got:\n%s", text)
	}
}

func TestBuildNativeExpandsClassesFeatMasterfeatRowsWithFilter(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
  "key": "classes/feats:fighter",
  "output": "cls_feat_fighter.2da",
  "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
  "rows": [
    {"FeatIndex": {"id": "feat:toughness", "filter": "successors"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
  "key": "classes/skills:fighter",
  "output": "cls_skill_fighter.2da",
  "columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
  "rows": [
    {"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
    {"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
    {"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
  "rows": [
    {"id": 1, "key": "feat:toughness", "LABEL": "Toughness", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "feat:toughness_1"},
    {"id": 2, "key": "feat:toughness_1", "LABEL": "Toughness1", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "feat:toughness_2"},
    {"id": 3, "key": "feat:toughness_2", "LABEL": "Toughness2", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
    {"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
    {"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
    {"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "203", "DESCRIPTION": "204", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:toughness": 1,
  "feat:toughness_1": 2,
  "feat:toughness_2": 3,
  "feat:skillfocus_athletics": 10,
  "feat:skillfocus_persuade": 11,
  "feat:skillfocus_alchemy": 12
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"},
    {"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": "6494", "DESCRIPTION": "427", "ICON": "ife_grtskfoc"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:skillfocus": 4,
  "masterfeats:greaterskillfocus": 5
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
	if err != nil {
		t.Fatalf("read cls_feat_fighter.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "2\tToughness\t1\t1\t-1\t0\n") || !strings.Contains(text, "3\tToughness1\t2\t1\t-1\t0\n") || !strings.Contains(text, "4\tToughness2\t3\t1\t-1\t0\n") {
		t.Fatalf("expected successor expansion, got:\n%s", text)
	}
}

func TestBuildNativeExpandsClassesFeatClassskillsFilter(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
  "key": "classes/feats:fighter",
  "output": "cls_feat_fighter.2da",
  "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
  "rows": [
    {"FeatIndex": {"id": "masterfeats:skillfocus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
  "key": "classes/skills:fighter",
  "output": "cls_skill_fighter.2da",
  "columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
  "rows": [
    {"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
    {"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
    {"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
  "rows": [
    {"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
    {"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
    {"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "203", "DESCRIPTION": "204", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:skillfocus_athletics": 10,
  "feat:skillfocus_persuade": 11,
  "feat:skillfocus_alchemy": 12
}`+"\n")
	/* CLASSKILLS_FILTER_TEST_INSERT_MARKER */
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"},
    {"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": "6494", "DESCRIPTION": "427", "ICON": "ife_grtskfoc"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:skillfocus": 4,
  "masterfeats:greaterskillfocus": 5
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
	if err != nil {
		t.Fatalf("read cls_feat_fighter.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "SkillFocusAthletics") || !strings.Contains(text, "SkillFocusPersuade") {
		t.Fatalf("expected classskill filter expansion (athletics + persuade), got:\n%s", text)
	}
	if strings.Contains(text, "SkillFocusAlchemy") {
		t.Fatalf("expected alchemy to be filtered out (not a class skill), got:\n%s", text)
	}
}

func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) {
	keyToID := map[string]int{
		"masterfeats:skill_focus":                   4,
		"masterfeats:greater_skill_focus":           15,
		"masterfeats:weapon_focus":                  1,
		"masterfeats:greater_weapon_specialization": 11,
	}

	cases := map[string]string{
		"masterfeats:skillfocus":                  "masterfeats:skill_focus",
		"masterfeats:greaterskillfocus":           "masterfeats:greater_skill_focus",
		"masterfeats:weaponfocus":                 "masterfeats:weapon_focus",
		"masterfeats:greaterweaponspecialization": "masterfeats:greater_weapon_specialization",
		"masterfeats:skill_focus":                 "masterfeats:skill_focus",
	}

	for input, want := range cases {
		if got := resolveCanonicalDatasetKey(input, keyToID); got != want {
			t.Fatalf("resolveCanonicalDatasetKey(%q) = %q, want %q", input, got, want)
		}
	}
}

func TestValidateProjectRejectsLegacyAuthoredTLKModules(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	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", "FEAT", "DESCRIPTION"],
  "rows": [
    {
      "id": 0,
      "key": "feat:test",
      "LABEL": "FEAT_TEST",
      "FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
      "DESCRIPTION": {"tlk": {"key": "feat:test.description", "text": "Test Description"}}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "test.json"), `{"entries":{"feat:test.name":{"text":"Legacy name"}}}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected validation error for legacy authored TLK modules")
	}
	if !strings.Contains(diagnosticsText(report.Diagnostics), "topdata/tlk/modules is no longer allowed for canonical authored input") {
		t.Fatalf("expected inline-TLK enforcement error, got:\n%s", diagnosticsText(report.Diagnostics))
	}
}

func TestNormalizeProjectRemovesLegacyAuthoredTLKModules(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	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", "FEAT", "DESCRIPTION"],
  "rows": [
    {
      "id": 0,
      "key": "feat:test",
      "LABEL": "FEAT_TEST",
      "FEAT": {"tlk": "feat:test.name"},
      "DESCRIPTION": {"tlk": "feat:test.description"}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{"feat:test.name":9,"feat:test.description":10}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "test.json"), `{
  "entries": {
    "feat:test.name": {"text": "Legacy Name"},
    "feat:test.description": {"text": "Legacy Description"}
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.RemainingLegacyRefs != 0 {
		t.Fatalf("expected no remaining legacy refs, got %#v", result)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "tlk", "modules")); !os.IsNotExist(err) {
		t.Fatalf("expected legacy tlk/modules to be removed, got err=%v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "tlk", "lock.json")); !os.IsNotExist(err) {
		t.Fatalf("expected legacy tlk/lock.json to be removed, got err=%v", err)
	}
	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected normalized project to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
	}
}

func TestValidateProjectAcceptsCanonicalMasterfeatsContract(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {
      "id": 0,
      "key": "masterfeats:weaponfocus",
      "LABEL": "WeaponFocus",
      "STRREF": "6490",
      "DESCRIPTION": "436",
      "ICON": "ife_wepfoc"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:weaponfocus": 0
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected no validation errors, got:\n%s", diagnosticsText(report.Diagnostics))
	}
}

func TestValidateAndBuildDerivesMasterfeatsOutputWhenOmitted(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
  "rows": [
    {
      "id": 0,
      "key": "masterfeats:weaponfocus",
      "LABEL": "WeaponFocus",
      "STRREF": "6490",
      "DESCRIPTION": "436",
      "ICON": "ife_wepfoc"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:weaponfocus": 0
}`+"\n")

	proj := testProject(root)
	report := ValidateProject(proj)
	if report.HasErrors() {
		t.Fatalf("expected omitted masterfeats output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
	}

	result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
	if err != nil {
		t.Fatalf("expected omitted masterfeats output to build: %v", err)
	}
	if _, err := os.Stat(filepath.Join(result.Output2DADir, "masterfeats.2da")); err != nil {
		t.Fatalf("expected derived masterfeats.2da output: %v", err)
	}
}

func TestValidateProjectRejectsInvalidMasterfeatsContract(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "STRREF"],
  "rows": [
    {
      "id": 0,
      "key": "feat:weaponfocus",
      "LABEL": "WeaponFocus",
      "STRREF": "6490"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "feat:weaponfocus": 0
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected validation errors for invalid masterfeats contract")
	}
	text := diagnosticsText(report.Diagnostics)
	for _, want := range []string{
		"masterfeats output must be masterfeats.2da",
		"masterfeats base.json must include DESCRIPTION in columns",
		`masterfeats row 0 key "feat:weaponfocus" must start with masterfeats:`,
		`masterfeats lock key "feat:weaponfocus" must start with masterfeats:`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected validation message %q, got:\n%s", want, text)
		}
	}
}

func TestBuildReferenceAndCompareReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL"],
  "rows": [{"id": 0, "LABEL": "TEST"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")

	p := testProject(root)

	result, err := BuildReference(p, nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	if result.Files2DA != 1 || result.FilesTLK != 1 {
		t.Fatalf("unexpected build result: %#v", result)
	}

	compare, err := CompareReference(p, nil)
	if err != nil {
		t.Fatalf("CompareReference failed: %v", err)
	}
	if compare.Mode != "native" || compare.NativeMismatch != 0 || compare.NativePass != 1 {
		t.Fatalf("unexpected compare result: %#v", compare)
	}
}

func TestCompareReferenceHandlesMixedModeOutputListMismatch(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER"],
  "rows": [{"id": 0, "COSTMODIFIER": "0"}]
}`+"\n")

	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	writeFile(t, filepath.Join(nativeResult.Output2DADir, "unexpected.2da"), "2DA V2.0\n\nLABEL\n0\tExtra\n")

	compare, err := CompareReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("CompareReference failed: %v", err)
	}
	if compare.NativeMismatch == 0 {
		t.Fatalf("unexpected mixed-mode compare result: %#v", compare)
	}
}

func TestBuildUsesNativeModeForInlineTLKAndWritesState(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "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", "FEAT", "DESCRIPTION"],
  "rows": [
    {"id": 0, "key": "feat:test", "LABEL": "FeatTest", "_tlk": {"name": "Base Name", "description": "Base Description"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0,"feat:module":5}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "module.json"), `{
  "entries": {
    "feat:module": {
      "LABEL": "Feat Module",
      "_tlk": {
        "name": {"ref": "feat:test", "field": "FEAT"},
        "description": {"text": "Module Description"}
      }
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	if report := ValidateProject(testProject(root)); report.HasErrors() {
		t.Fatalf("unexpected validation errors:\n%s", diagnosticsText(report.Diagnostics))
	}

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}
	if result.Files2DA != 1 || result.FilesTLK != 1 {
		t.Fatalf("unexpected build result: %#v", result)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read native 2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tFEAT\tDESCRIPTION\n") ||
		!strings.Contains(text, "0\tFeatTest\t16777216\t16777217\n") ||
		!strings.Contains(text, "5\t\"Feat Module\"\t16777216\t16777218\n") {
		t.Fatalf("unexpected 2da output:\n%s", string(got))
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	var state tlkStateDocument
	if err := json.Unmarshal(stateRaw, &state); err != nil {
		t.Fatalf("parse state: %v", err)
	}
	if state.Language != "en" {
		t.Fatalf("unexpected state language: %#v", state)
	}
	if state.Entries["feat:test.feat"].ID != 0 || state.Entries["feat:test.description"].ID != 1 || state.Entries["feat:module.description"].ID != 2 {
		t.Fatalf("unexpected state entries: %#v", state.Entries)
	}

	if _, err := os.Stat(filepath.Join(result.OutputTLKDir, defaultTLKName)); err != nil {
		t.Fatalf("expected tlk output: %v", err)
	}
}

func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description"],
  "rows": [
    {"id": 0, "key": "skills:athletics", "Label": "Athletics", "_tlk": {"name": "Athletics", "description": "Old text"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	p := testProject(root)
	if _, err := Build(p, nil); err != nil {
		t.Fatalf("first build failed: %v", err)
	}

	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description"],
  "rows": [
    {"id": 0, "key": "skills:athletics", "Label": "Athletics", "_tlk": {"name": "Athletics", "description": "New text"}}
  ]
}`+"\n")
	if _, err := Build(p, nil); err != nil {
		t.Fatalf("second build failed: %v", err)
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	var state tlkStateDocument
	if err := json.Unmarshal(stateRaw, &state); err != nil {
		t.Fatalf("parse state: %v", err)
	}
	if state.Entries["skills:athletics.name"].ID != 0 || state.Entries["skills:athletics.description"].ID != 1 {
		t.Fatalf("unexpected stable IDs after rebuild: %#v", state.Entries)
	}
}

func TestBuildAutoTLKKeysUseLiteralColumnNamesForIDRowsAndPlainTables(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description"],
  "rows": [
    {"id": 0, "Label": "Athletics", "Name": {"tlk": {"text": "Athletics"}}, "Description": {"tlk": {"text": "Athletics description"}}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["LABEL", "NAME"],
  "rows": [
    {"key": "parts:belt:test", "LABEL": "BeltTest", "NAME": {"tlk": {"text": "Belt Name"}}}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	if _, err := Build(testProject(root), nil); err != nil {
		t.Fatalf("Build failed: %v", err)
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	var state tlkStateDocument
	if err := json.Unmarshal(stateRaw, &state); err != nil {
		t.Fatalf("parse state: %v", err)
	}
	idName, ok := state.Entries["skills:id:0.name"]
	if !ok {
		t.Fatalf("missing id-row name auto key: %#v", state.Entries)
	}
	idDescription, ok := state.Entries["skills:id:0.description"]
	if !ok {
		t.Fatalf("missing id-row description auto key: %#v", state.Entries)
	}
	plainName, ok := state.Entries["parts:belt:test.name"]
	if !ok {
		t.Fatalf("missing plain-table auto key: %#v", state.Entries)
	}
	if idName.ID == idDescription.ID || idName.ID == plainName.ID || idDescription.ID == plainName.ID {
		t.Fatalf("expected distinct ids for auto-generated keys, got %#v", state.Entries)
	}
}

func TestBuildAutoTLKKeyConflictsFailClearly(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	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", "FEAT"],
  "rows": [
    {"id": 0, "key": "feat:test", "LABEL": "FeatTest", "_tlk": {"name": "Auto Name"}},
    {"id": 1, "key": "feat:other", "LABEL": "FeatOther", "FEAT": {"tlk": {"key": "feat:test.feat", "text": "Different Name"}}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0,"feat:other":1}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	_, err := Build(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), `conflicting TLK content for key "feat:test.feat"`) {
		t.Fatalf("expected TLK conflict, got %v", err)
	}
}

func TestBuildRejectsLegacyAuthoredTLKRefs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "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", "FEAT", "DESCRIPTION"],
  "rows": [
    {"id": 0, "key": "feat:test", "LABEL": "FeatTest", "FEAT": {"tlk": "feat:test.name"}, "DESCRIPTION": {"tlk": "feat:test.description"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	_, err := Build(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), "inline TLK text is required for native builds") {
		t.Fatalf("expected legacy bare tlk ref failure, got %v", err)
	}
}

func TestBuildSupportsPlainTableDatasets(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": [
    {"id": 0, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
    {"key": "parts:belt:test", "COSTMODIFIER": "1", "ACBONUS": "0.25"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
	if err != nil {
		t.Fatalf("read 2da: %v", err)
	}
	want := "2DA V2.0\n\nCOSTMODIFIER\tACBONUS\n0\t0\t0.00\n1\t1\t0.25\n"
	if string(got) != want {
		t.Fatalf("unexpected plain-table output:\n%s", string(got))
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "parts", "lock.json"))
	if err != nil {
		t.Fatalf("read lockfile: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"parts:belt:test": 1`) {
		t.Fatalf("expected generated plain-table lock entry, got:\n%s", string(lockRaw))
	}
}

func TestBuildSurfacesNativeFailuresWithoutReferenceFallback(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "broken"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "broken", "base.json"), `{
  "output": "broken.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": "bad", "LABEL": "Broken"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "broken", "lock.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	_, err := Build(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), "row id bad is not numeric") {
		t.Fatalf("expected native build error, got %v", err)
	}
}

func TestBuildAppliesNonTLKOverrides(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "base.json"), `{
  "output": "itemprops.2da",
  "columns": ["0_Melee", "6_Arm_Shld", "StringRef", "Label"],
  "rows": [
    {"id": 81, "0_Melee": "****", "6_Arm_Shld": "****", "StringRef": "58325", "Label": "Weight_Increase"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "modules", "ovr_eos.json"), `{
  "overrides": [
    {"id": 81, "6_Arm_Shld": "1"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "itemprops.2da"))
	if err != nil {
		t.Fatalf("read 2da: %v", err)
	}
	if !strings.Contains(string(got), "81\t****\t1\t58325\tWeight_Increase\n") {
		t.Fatalf("expected override-applied row, got:\n%s", string(got))
	}
}

func TestBuildFallsBackWhenProjectMigrationIsIncomplete(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER"],
  "rows": [{"id": 0, "COSTMODIFIER": "0"}]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "parts"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "feat"))
	writeFile(t, filepath.Join(root, "reference", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER"],
  "rows": [{"id": 0, "COSTMODIFIER": "0"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL"],
  "rows": [{"id": 0, "LABEL": "Feat"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "parts_belt.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\\n\\nCOSTMODIFIER\\n0\\t0\\n")
with open(os.path.join(out, "feat.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\\n\\nLABEL\\n0\\tFeat\\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}
}

func TestNormalizeProjectInlinesLegacyTLKReferences(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	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", "FEAT", "DESCRIPTION"],
  "rows": [
    {"id": 0, "key": "feat:test", "LABEL": "FeatTest", "FEAT": {"tlk": "feat:test.name"}, "DESCRIPTION": {"tlk": "feat:test.description"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{"feat:test.name":9,"feat:test.description":10}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "test.json"), `{
  "entries": {
    "feat:test": {
      "name": {"text": "Legacy Name"},
      "description": {"text": "Legacy Description"}
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles == 0 || result.InlineValues != 2 {
		t.Fatalf("unexpected normalize result: %#v", result)
	}

	normalized, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "base.json"))
	if err != nil {
		t.Fatalf("read normalized file: %v", err)
	}
	text := string(normalized)
	if !strings.Contains(text, `"key": "feat:test.name"`) || !strings.Contains(text, `"text": "Legacy Name"`) {
		t.Fatalf("expected inlined TLK payloads, got:\n%s", text)
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read state: %v", err)
	}
	var state tlkStateDocument
	if err := json.Unmarshal(stateRaw, &state); err != nil {
		t.Fatalf("parse state: %v", err)
	}
	if state.Entries["feat:test.name"].ID != 9 || state.Entries["feat:test.description"].ID != 10 {
		t.Fatalf("expected state seeded from legacy TLK lock, got %#v", state.Entries)
	}
}

func TestNormalizeProjectHandlesPlainTablesAndReportsRemainingRefs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION"],
  "rows": [
    {"key": "masterfeats:test", "LABEL": "Test", "STRREF": {"tlk": "masterfeats:test.name"}, "DESCRIPTION": {"tlk": "masterfeats:test.description"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{"masterfeats:test.name":9,"masterfeats:test.description":10}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "masterfeats.json"), `{
  "entries": {
    "masterfeats:test": {
      "name": {"text": "Masterfeat Name"},
      "description": {"text": "Masterfeat Description"}
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.ScannedFiles != 1 || result.UpdatedFiles != 1 || result.InlineValues != 2 {
		t.Fatalf("unexpected normalize result: %#v", result)
	}
	if result.RemainingFiles != 0 || result.RemainingLegacyRefs != 0 {
		t.Fatalf("expected no remaining legacy refs, got %#v", result)
	}

	normalized, err := os.ReadFile(filepath.Join(root, "topdata", "data", "masterfeats", "base.json"))
	if err != nil {
		t.Fatalf("read normalized file: %v", err)
	}
	text := string(normalized)
	if !strings.Contains(text, `"text": "Masterfeat Name"`) || !strings.Contains(text, `"text": "Masterfeat Description"`) {
		t.Fatalf("expected normalized inline TLK payloads, got:\n%s", text)
	}
}

func TestBuildSupportsCanonicalItempropsRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "registry"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"), `{
  "rows": [
    {
      "key": "itemprop:basearmorclass",
      "label": "BaseArmorClass",
      "name": {"tlk": {"key": "itemprops:basearmorclass.name", "text": "Base Armor Class"}},
      "property_text": {"tlk": {"key": "itemprops:basearmorclass.property_text", "text": "Base armor class"}},
      "availability": ["arm_shld"],
      "cost": {"value": "0", "ref": "cost_models:iprpbaseaccost"}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "cost_models.json"), `{
  "rows": [
    {
      "key": "cost_models:iprpbaseaccost",
      "index_name": "iprp_baseaccost",
      "label": "IPRP_BASEACCOST",
      "client_load": "0",
      "table": {
        "ownership": "owned",
        "resref": "iprp_baseaccost",
        "output": "iprp_baseaccost.2da",
        "columns": ["Name", "Cost"],
        "rows": [
          {"id": 0, "Name": "0", "Cost": "0"}
        ]
      }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "param_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "subtype_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"), `{"itemprop:basearmorclass":31,"cost_models:iprpbaseaccost":31}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	if report := ValidateProject(testProject(root)); report.HasErrors() {
		t.Fatalf("unexpected validation errors:\n%s", diagnosticsText(report.Diagnostics))
	}

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}
	if result.Files2DA != 5 || result.FilesTLK != 1 {
		t.Fatalf("unexpected build result: %#v", result)
	}

	itempropsOut, err := os.ReadFile(filepath.Join(result.Output2DADir, "itemprops.2da"))
	if err != nil {
		t.Fatalf("read itemprops.2da: %v", err)
	}
	text := string(itempropsOut)
	if !strings.Contains(text, "31\t****\t****\t****\t****\t****\t****\t1\t") || !strings.Contains(text, "\t16777216\tBaseArmorClass\n") {
		t.Fatalf("unexpected itemprops.2da output:\n%s", text)
	}

	defOut, err := os.ReadFile(filepath.Join(result.Output2DADir, "itempropdef.2da"))
	if err != nil {
		t.Fatalf("read itempropdef.2da: %v", err)
	}
	if !strings.Contains(string(defOut), "31\t16777216\tBaseArmorClass\t****\t0\t31\t****\t16777217\t****\n") {
		t.Fatalf("unexpected itempropdef.2da output:\n%s", string(defOut))
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	if !strings.Contains(string(stateRaw), `"itemprops:basearmorclass.name"`) || !strings.Contains(string(stateRaw), `"itemprops:basearmorclass.property_text"`) {
		t.Fatalf("expected itemprops TLK keys in state, got:\n%s", string(stateRaw))
	}
}

func TestNormalizeProjectImportsLegacyItempropsRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops"))
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "itemprops:basearmorclass.name": 384,
  "itemprops:basearmorclass.property_text": 385
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops", "add_itemprops.json"), `{
  "entries": {
    "itemprops:basearmorclass.name": {"text": "Base Armor Class"},
    "itemprops:basearmorclass.property_text": {"text": "Base armor class"}
  }
}`+"\n")

	refData := filepath.Join(root, "reference", "data", "itemprops")
	mkdirAll(t, filepath.Join(refData, "defs", "modules"))
	mkdirAll(t, filepath.Join(refData, "sets", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "index", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "baseaccost"))
	mkdirAll(t, filepath.Join(refData, "paramtables", "index"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(refData, "defs", "base.json"), `{
  "output": "itempropdef.2da",
  "columns": ["Name","Label","SubTypeResRef","Cost","CostTableResRef","Param1ResRef","GameStrRef","Description"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "lock.json"), `{"itempropdef:basearmorclass":31}`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "modules", "add_eos.json"), `{
  "entries": {
    "itempropdef:basearmorclass": {
      "Name": {"tlk": "itemprops:basearmorclass.name"},
      "Label": "BaseArmorClass",
      "SubTypeResRef": "****",
      "Cost": "0",
      "CostTableResRef": "31",
      "Param1ResRef": "****",
      "GameStrRef": {"tlk": "itemprops:basearmorclass.property_text"},
      "Description": "****"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "base.json"), `{
  "output": "itemprops.2da",
  "columns": ["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","StringRef","Label"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "lock.json"), `{"itemprops:basearmorclass":31}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "modules", "add_eos.json"), `{
  "entries": {
    "itemprops:basearmorclass": {
      "6_Arm_Shld": "1",
      "StringRef": {"tlk": "itemprops:basearmorclass.name"},
      "Label": "BaseArmorClass"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "base.json"), `{
  "output": "iprp_costtable.2da",
  "columns": ["Name","Label","ClientLoad"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "lock.json"), `{"itemprops:costtable_index:iprp_baseaccost":31}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "modules", "add_eos.json"), `{
  "entries": {
    "itemprops:costtable_index:iprp_baseaccost": {
      "Name": "iprp_baseaccost",
      "Label": "IPRP_BASEACCOST",
      "ClientLoad": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "baseaccost", "base.json"), `{
  "output": "iprp_baseaccost.2da",
  "columns": ["Name","Cost"],
  "rows": [{"id": 0, "Name": "0", "Cost": "0"}]
}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "base.json"), `{
  "output": "iprp_paramtable.2da",
  "columns": ["Name","Lable","TableResRef"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "lock.json"), `{} `+"\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 5 {
		t.Fatalf("expected registry files to be imported, got %#v", result)
	}

	propertiesRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"))
	if err != nil {
		t.Fatalf("read properties.json: %v", err)
	}
	text := string(propertiesRaw)
	if !strings.Contains(text, `"itemprop:basearmorclass"`) || !strings.Contains(text, `"text": "Base Armor Class"`) || !strings.Contains(text, `"availability": [`) {
		t.Fatalf("unexpected imported properties.json:\n%s", text)
	}
	if strings.Contains(text, `"tlk": "itemprops:basearmorclass.name"`) {
		t.Fatalf("expected imported itemprops TLK text to be fully inlined, got:\n%s", text)
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"))
	if err != nil {
		t.Fatalf("read registry lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"itemprop:basearmorclass": 31`) || !strings.Contains(string(lockRaw), `"cost_models:iprpbaseaccost": 31`) {
		t.Fatalf("unexpected registry lock:\n%s", string(lockRaw))
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	if !strings.Contains(string(stateRaw), `"itemprops:basearmorclass.name"`) || !strings.Contains(string(stateRaw), `"id": 384`) {
		t.Fatalf("expected imported itemprops ids to seed tlk state, got:\n%s", string(stateRaw))
	}
}

func TestNormalizeProjectRefreshesLegacyItempropsRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "registry"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"), `{
  "rows": [
    {
      "key": "itemprop:basearmorclass",
      "label": "BaseArmorClass",
      "name": {"tlk": "itemprops:basearmorclass.name"},
      "property_text": {"tlk": "itemprops:basearmorclass.property_text"},
      "availability": ["arm_shld"],
      "cost": {"value": "0", "ref": "cost_models:iprpbaseaccost"}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "cost_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "param_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "subtype_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"), `{} `+"\n")
	mkdirAll(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops"))
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "itemprops:basearmorclass.name": 384,
  "itemprops:basearmorclass.property_text": 385
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops", "add_itemprops.json"), `{
  "entries": {
    "itemprops:basearmorclass.name": {"text": "Base Armor Class"},
    "itemprops:basearmorclass.property_text": {"text": "Base armor class"}
  }
}`+"\n")
	refData := filepath.Join(root, "reference", "data", "itemprops")
	mkdirAll(t, filepath.Join(refData, "defs", "modules"))
	mkdirAll(t, filepath.Join(refData, "sets", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "index", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "baseaccost"))
	mkdirAll(t, filepath.Join(refData, "paramtables", "index"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(refData, "defs", "base.json"), `{"output":"itempropdef.2da","columns":["Name","Label","SubTypeResRef","Cost","CostTableResRef","Param1ResRef","GameStrRef","Description"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "lock.json"), `{"itempropdef:basearmorclass":31}`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "modules", "add_eos.json"), `{"entries":{"itempropdef:basearmorclass":{"Name":{"tlk":"itemprops:basearmorclass.name"},"Label":"BaseArmorClass","SubTypeResRef":"****","Cost":"0","CostTableResRef":"31","Param1ResRef":"****","GameStrRef":{"tlk":"itemprops:basearmorclass.property_text"},"Description":"****"}}}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "base.json"), `{"output":"itemprops.2da","columns":["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","StringRef","Label"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "lock.json"), `{"itemprops:basearmorclass":31}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "modules", "add_eos.json"), `{"entries":{"itemprops:basearmorclass":{"6_Arm_Shld":"1","StringRef":{"tlk":"itemprops:basearmorclass.name"},"Label":"BaseArmorClass"}}}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "base.json"), `{"output":"iprp_costtable.2da","columns":["Name","Label","ClientLoad"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "lock.json"), `{"itemprops:costtable_index:iprp_baseaccost":31}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "modules", "add_eos.json"), `{"entries":{"itemprops:costtable_index:iprp_baseaccost":{"Name":"iprp_baseaccost","Label":"IPRP_BASEACCOST","ClientLoad":"0"}}}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "baseaccost", "base.json"), `{"output":"iprp_baseaccost.2da","columns":["Name","Cost"],"rows":[{"id":0,"Name":"0","Cost":"0"}]}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "base.json"), `{"output":"iprp_paramtable.2da","columns":["Name","Lable","TableResRef"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "lock.json"), `{} `+"\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 5 {
		t.Fatalf("expected registry refresh, got %#v", result)
	}
	propertiesRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"))
	if err != nil {
		t.Fatalf("read properties.json: %v", err)
	}
	if strings.Contains(string(propertiesRaw), `"tlk": "itemprops:basearmorclass.name"`) {
		t.Fatalf("expected refreshed registry to inline TLK text, got:\n%s", string(propertiesRaw))
	}
}

func TestNormalizeProjectPreservesItempropsSetProjectionDifferences(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "itemprops", "defs", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "itemprops", "sets", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "itemprops", "costtables", "index"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "itemprops", "paramtables", "index"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "defs", "base.json"), `{
  "output": "itempropdef.2da",
  "columns": ["Name","Label","SubTypeResRef","Cost","CostTableResRef","Param1ResRef","GameStrRef","Description"],
  "rows": [
    {"id":82,"key":"itempropdef:onhitcastspell","Name":"83400","Label":"OnHitCastSpell","SubTypeResRef":"IPRP_ONHITSPELL","Cost":"****","CostTableResRef":"26","Param1ResRef":"****","GameStrRef":"5512","Description":"1450"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "defs", "lock.json"), `{"itempropdef:onhitcastspell":82}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "sets", "base.json"), `{
  "output": "itemprops.2da",
  "columns": ["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","StringRef","Label"],
  "rows": [
    {"id":82,"0_Melee":"1","1_Ranged":"****","2_Thrown":"1","3_Staves":"1","4_Rods":"****","5_Ammo":"1","6_Arm_Shld":"1","7_Helm":"****","8_Potions":"****","9_Scrolls":"****","10_Wands":"****","11_Thieves":"****","12_TrapKits":"****","13_Hide":"1","14_Claw":"1","15_Misc_Uneq":"****","16_Misc":"****","17_No_Props":"****","18_Containers":"****","19_HealerKit":"****","20_Torch":"****","21_Glove":"1","StringRef":"723","Label":"On_Hit_Cast_Spell"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "sets", "lock.json"), `{"itemprops:onhitcastspell":82}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "costtables", "index", "base.json"), `{
  "output": "iprp_costtable.2da",
  "columns": ["Name","Label","ClientLoad"],
  "rows": [{"id":26,"Name":"iprp_spellcst","Label":"IPRP_SPELLCST","ClientLoad":"0"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "costtables", "index", "lock.json"), `{"cost_models:iprpspellcstr":26}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "paramtables", "index", "base.json"), `{"output":"iprp_paramtable.2da","columns":["Name","Lable","TableResRef"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "itemprops", "paramtables", "index", "lock.json"), `{} `+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}

	propertiesRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"))
	if err != nil {
		t.Fatalf("read properties.json: %v", err)
	}
	text := string(propertiesRaw)
	for _, want := range []string{`"label": "OnHitCastSpell"`, `"set_label": "On_Hit_Cast_Spell"`, `"name": "83400"`, `"set_name": "723"`} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected %s in imported registry, got:\n%s", want, text)
		}
	}
}

func TestItempropsRegistryMatchesLegacyReferenceOutputs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops"))
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "itemprops:basearmorclass.name": 384,
  "itemprops:basearmorclass.property_text": 385,
  "itemprops:maximumdexteritybonus.name": 386,
  "itemprops:maximumdexteritybonus.property_text": 387,
  "itemprops:armorcheckpenalty.name": 388,
  "itemprops:armorcheckpenalty.property_text": 389
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "modules", "itemprops", "add_itemprops.json"), `{
  "entries": {
    "itemprops:basearmorclass.name": {"text": "Base Armor Class"},
    "itemprops:basearmorclass.property_text": {"text": "Base Armor Class:"},
    "itemprops:maximumdexteritybonus.name": {"text": "Maximum Dexterity Bonus"},
    "itemprops:maximumdexteritybonus.property_text": {"text": "Maximum Dexterity Bonus:"},
    "itemprops:armorcheckpenalty.name": {"text": "Armor Check Penalty"},
    "itemprops:armorcheckpenalty.property_text": {"text": "Armor Check Penalty:"}
  }
}`+"\n")
	refData := filepath.Join(root, "reference", "data", "itemprops")
	mkdirAll(t, filepath.Join(refData, "defs", "modules"))
	mkdirAll(t, filepath.Join(refData, "sets", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "index", "modules"))
	mkdirAll(t, filepath.Join(refData, "costtables", "baseaccost"))
	mkdirAll(t, filepath.Join(refData, "costtables", "maxdexcost"))
	mkdirAll(t, filepath.Join(refData, "costtables", "accheckcost"))
	mkdirAll(t, filepath.Join(refData, "paramtables", "index"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import json, os, sys
root = os.getcwd()
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
def write_table(src_rel):
    with open(os.path.join(root, "data", src_rel), "r", encoding="utf-8") as f:
        obj = json.load(f)
    cols = obj["columns"]
    rows = obj["rows"]
    out_name = obj["output"]
    with open(os.path.join(out, out_name), "w", encoding="utf-8") as f:
        f.write("2DA V2.0\\n\\n")
        f.write("\\t".join(cols) + "\\n")
        for row in rows:
            vals = [str(row.get("id", ""))]
            for c in cols:
                vals.append(str(row[c]))
            f.write("\\t".join(vals) + "\\n")
for rel in [
    "itemprops/sets/base.json",
    "itemprops/defs/base.json",
    "itemprops/costtables/index/base.json",
    "itemprops/paramtables/index/base.json",
    "itemprops/costtables/baseaccost/base.json",
    "itemprops/costtables/maxdexcost/base.json",
    "itemprops/costtables/accheckcost/base.json",
]:
    write_table(rel)
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "base.json"), `{
  "output": "itempropdef.2da",
  "columns": ["Name","Label","SubTypeResRef","Cost","CostTableResRef","Param1ResRef","GameStrRef","Description"],
  "rows": [
    {"id":31,"key":"itempropdef:basearmorclass","Name":{"tlk":"itemprops:basearmorclass.name"},"Label":"BaseArmorClass","SubTypeResRef":"****","Cost":"0","CostTableResRef":"31","Param1ResRef":"****","GameStrRef":{"tlk":"itemprops:basearmorclass.property_text"},"Description":"****"},
    {"id":32,"key":"itempropdef:maximumdexteritybonus","Name":{"tlk":"itemprops:maximumdexteritybonus.name"},"Label":"MaximumDexterityBonus","SubTypeResRef":"****","Cost":"0","CostTableResRef":"32","Param1ResRef":"****","GameStrRef":{"tlk":"itemprops:maximumdexteritybonus.property_text"},"Description":"****"},
    {"id":33,"key":"itempropdef:armorcheckpenalty","Name":{"tlk":"itemprops:armorcheckpenalty.name"},"Label":"ArmorCheckPenalty","SubTypeResRef":"****","Cost":"0","CostTableResRef":"33","Param1ResRef":"****","GameStrRef":{"tlk":"itemprops:armorcheckpenalty.property_text"},"Description":"****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(refData, "defs", "lock.json"), `{"itempropdef:basearmorclass":31,"itempropdef:maximumdexteritybonus":32,"itempropdef:armorcheckpenalty":33}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "base.json"), `{
  "output": "itemprops.2da",
  "columns": ["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","StringRef","Label"],
  "rows": [
    {"id":31,"key":"itemprops:basearmorclass","0_Melee":"****","1_Ranged":"****","2_Thrown":"****","3_Staves":"****","4_Rods":"****","5_Ammo":"****","6_Arm_Shld":"1","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":"****","StringRef":{"tlk":"itemprops:basearmorclass.name"},"Label":"BaseArmorClass"},
    {"id":32,"key":"itemprops:maximumdexteritybonus","0_Melee":"****","1_Ranged":"****","2_Thrown":"****","3_Staves":"****","4_Rods":"****","5_Ammo":"****","6_Arm_Shld":"1","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":"****","StringRef":{"tlk":"itemprops:maximumdexteritybonus.name"},"Label":"MaximumDexterityBonus"},
    {"id":33,"key":"itemprops:armorcheckpenalty","0_Melee":"****","1_Ranged":"****","2_Thrown":"****","3_Staves":"****","4_Rods":"****","5_Ammo":"****","6_Arm_Shld":"1","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":"****","StringRef":{"tlk":"itemprops:armorcheckpenalty.name"},"Label":"ArmorCheckPenalty"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(refData, "sets", "lock.json"), `{"itemprops:basearmorclass":31,"itemprops:maximumdexteritybonus":32,"itemprops:armorcheckpenalty":33}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "base.json"), `{
  "output": "iprp_costtable.2da",
  "columns": ["Name","Label","ClientLoad"],
  "rows": [
    {"id":31,"Name":"iprp_baseaccost","Label":"IPRP_BASEACCOST","ClientLoad":"0"},
    {"id":32,"Name":"iprp_maxdexcost","Label":"IPRP_MAXDEXCOST","ClientLoad":"0"},
    {"id":33,"Name":"iprp_accheckcost","Label":"IPRP_ACCHECKCOST","ClientLoad":"0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "index", "lock.json"), `{"itemprops:costtable_index:iprp_baseaccost":31,"itemprops:costtable_index:iprp_maxdexcost":32,"itemprops:costtable_index:iprp_accheckcost":33}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "base.json"), `{"output":"iprp_paramtable.2da","columns":["Name","Lable","TableResRef"],"rows":[]}`+"\n")
	writeFile(t, filepath.Join(refData, "paramtables", "index", "lock.json"), `{} `+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "baseaccost", "base.json"), `{"output":"iprp_baseaccost.2da","columns":["Name","Cost"],"rows":[{"id":0,"Name":"0","Cost":"0"}]}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "maxdexcost", "base.json"), `{"output":"iprp_maxdexcost.2da","columns":["Name","Cost"],"rows":[{"id":0,"Name":"0","Cost":"0"}]}`+"\n")
	writeFile(t, filepath.Join(refData, "costtables", "accheckcost", "base.json"), `{"output":"iprp_accheckcost.2da","columns":["Name","Cost"],"rows":[{"id":0,"Name":"0","Cost":"0"}]}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	for _, outputName := range []string{"itemprops.2da", "itempropdef.2da", "iprp_costtable.2da", "iprp_paramtable.2da"} {
		nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, outputName))
		if err != nil {
			t.Fatalf("read native %s: %v", outputName, err)
		}
		referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, outputName))
		if err != nil {
			t.Fatalf("read reference %s: %v", outputName, err)
		}
		if string(nativeBytes) != string(referenceBytes) {
			t.Fatalf("output mismatch for %s\nnative:\n%s\nreference:\n%s", outputName, string(nativeBytes), string(referenceBytes))
		}
	}
	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	for _, want := range []string{`"itemprops:basearmorclass.name"`, `"itemprops:maximumdexteritybonus.name"`, `"itemprops:armorcheckpenalty.name"`} {
		if !strings.Contains(string(stateRaw), want) {
			t.Fatalf("expected %s in tlk state, got:\n%s", want, string(stateRaw))
		}
	}
}

func TestValidateProjectRejectsBrokenItempropsRegistryRefs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "registry"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"), `{
  "rows": [
    {
      "key": "itemprop:test",
      "label": "DuplicateLabel",
      "name": {"tlk": {"text": "Test"}},
      "property_text": {"tlk": {"text": "Prop Text"}},
      "availability": ["not_a_group"],
      "cost": {"ref": "cost_models:missing"},
      "param": {"ref": "param_models:missing"},
      "subtype": {"ref": "subtype_models:missing"}
    },
    {
      "key": "itemprop:test2",
      "label": "DuplicateLabel",
      "name": {"tlk": {"text": "Other"}},
      "property_text": {"tlk": {"text": "Other Prop"}}
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "cost_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "param_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "subtype_models.json"), `{"rows":[]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"), `{} `+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
	}
	joined := diagnosticsText(report.Diagnostics)
	for _, want := range []string{
		"unknown availability group",
		"unknown cost ref cost_models:missing",
		"unknown param ref param_models:missing",
		"unknown subtype ref subtype_models:missing",
		"generated itempropdef label collision",
		"generated itemprops set label collision",
	} {
		if !strings.Contains(joined, want) {
			t.Fatalf("expected validation message %q, got:\n%s", want, joined)
		}
	}
}

func TestValidateProjectRejectsInvalidInheritanceUsage(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "PARENT", "VALUE"],
  "rows": [
    {
      "key": "custom:child",
      "LABEL": "Child",
      "inherit": {
        "from": "PARENT",
        "fields": ["VALUE", "MISSING"]
      }
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
	}
	joined := diagnosticsText(report.Diagnostics)
	for _, want := range []string{
		`inherit.from field "PARENT" was not found on row`,
		`inherit field "MISSING" is not a known column`,
	} {
		if !strings.Contains(joined, want) {
			t.Fatalf("expected validation message %q, got:\n%s", want, joined)
		}
	}
}

func TestBuildSupportsGenericInheritanceSugar(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "PARENT", "VALUE", "ICON"],
  "rows": [
    {"id": 0, "key": "custom:parent", "LABEL": "Parent", "VALUE": "7", "ICON": "parent_icon"},
    {"id": 1, "key": "custom:child", "LABEL": "Child", "PARENT": {"id": "custom:parent"}, "inherit": {"from": "PARENT", "fields": ["VALUE", "ICON"]}},
    {"id": 2, "key": "custom:override", "LABEL": "Override", "PARENT": {"id": "custom:parent"}, "VALUE": "9", "inherit": {"from": "PARENT", "fields": ["VALUE", "ICON"]}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
  "custom:parent": 0,
  "custom:child": 1,
  "custom:override": 2
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	if result.Mode != "native" {
		t.Fatalf("expected native mode, got %#v", result)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
	if err != nil {
		t.Fatalf("read custom.2da: %v", err)
	}
	want := "2DA V2.0\n\nLABEL\tPARENT\tVALUE\tICON\n0\tParent\t****\t7\tparent_icon\n1\tChild\t0\t7\tparent_icon\n2\tOverride\t0\t9\tparent_icon\n"
	if string(got) != want {
		t.Fatalf("unexpected custom.2da output:\n%s", string(got))
	}
}

func TestBuildRejectsUnresolvedInheritedParent(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "PARENT", "VALUE"],
  "rows": [
    {"id": 0, "key": "custom:child", "LABEL": "Child", "PARENT": {"field": "VALUE", "ref": "custom:parent"}, "inherit": {"from": "PARENT", "fields": ["VALUE"]}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{"custom:child":0}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	_, err := BuildNative(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), `inherit.from field "PARENT" must resolve to a keyed parent row reference`) {
		t.Fatalf("expected inherit parent error, got %v", err)
	}
}

func TestValidateProjectRejectsInvalidGenericInheritanceUsage(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "DETAILS"],
  "rows": [
    {
      "key": "custom:child",
      "LABEL": "Child",
      "DETAILS": {
        "inherit": {
          "ref": "not_a_key"
        },
        "foo": "bar"
      }
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
	}
	joined := diagnosticsText(report.Diagnostics)
	if !strings.Contains(joined, `inherit.ref "not_a_key" must use dataset:key identity`) {
		t.Fatalf("expected generic inherit validation message, got:\n%s", joined)
	}
}

func TestBuildSupportsGenericNestedObjectInheritance(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "DETAILS"],
  "rows": [
    {
      "id": 0,
      "key": "custom:parent",
      "LABEL": "Parent",
      "DETAILS": {
        "flags": ["base"],
        "meta": {
          "cost": "5",
          "icon": "base_icon"
        }
      }
    },
    {
      "id": 1,
      "key": "custom:child",
      "LABEL": "Child",
      "DETAILS": {
        "inherit": {
          "ref": "custom:parent",
          "field": "DETAILS"
        },
        "flags": ["local"],
        "meta": {
          "cost": "9"
        }
      }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
  "custom:parent": 0,
  "custom:child": 1
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
	if err != nil {
		t.Fatalf("read custom.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, `{"flags":["base"],"meta":{"cost":"5","icon":"base_icon"}}`) {
		t.Fatalf("expected parent object in output, got:\n%s", text)
	}
	if !strings.Contains(text, `{"flags":["local"],"meta":{"cost":"9","icon":"base_icon"}}`) {
		t.Fatalf("expected merged child object in output, got:\n%s", text)
	}
}

func TestBuildRejectsCyclicGenericInheritance(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
  "output": "custom.2da",
  "columns": ["LABEL", "VALUE"],
  "rows": [
    {
      "id": 0,
      "key": "custom:a",
      "LABEL": "A",
      "inherit": {
        "ref": "custom:b"
      }
    },
    {
      "id": 1,
      "key": "custom:b",
      "LABEL": "B",
      "inherit": {
        "ref": "custom:a"
      }
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
  "custom:a": 0,
  "custom:b": 1
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	_, err := Build(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), "cyclic inheritance detected") {
		t.Fatalf("expected cyclic inheritance error, got %v", err)
	}
}

func TestBuildSupportsFeatInheritanceFromMasterfeats(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "PreReqEpic"],
  "rows": [
    {
      "id": 4,
      "key": "masterfeats:skillfocus",
      "LABEL": "SkillFocus",
      "STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}},
      "DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}},
      "ICON": "ife_skfoc",
      "PreReqEpic": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description"],
  "rows": [
    {"id": 31, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Athletics description"}}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":31}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION", "ICON", "MASTERFEAT", "REQSKILL", "PreReqEpic"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:skillfocus_athletics":1158}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfocus.json"), `{
  "entries": {
    "feat:skillfocus_athletics": {
      "inherit": {
        "ref": "masterfeats:skillfocus"
      },
      "LABEL": "FEAT_SKILL_FOCUS_ATHLETICS",
      "FEAT": {
        "tlk": {
          "key": "feat:skillfocus_athletics.name",
          "text": "Skill Focus: Athletics"
        }
      },
      "MASTERFEAT": {
        "id": "masterfeats:skillfocus"
      },
      "REQSKILL": {
        "id": "skills:athletics"
      }
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")

	result, err := Build(testProject(root), nil)
	if err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "1158\tFEAT_SKILL_FOCUS_ATHLETICS\t16777216\t16777217\tife_skfoc\t4\t31\t0\n") {
		t.Fatalf("expected inherited feat row, got:\n%s", text)
	}
}

func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL", "FEAT", "DESCRIPTION"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
  "family": "skill_focus",
  "family_key": "skillfocus",
  "template": "masterfeats:skillfocus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "child_source": {"dataset":"skills","predicate":"accessible"},
  "overrides": {}
}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatal("expected validation errors for invalid feat generated file")
	}
	text := diagnosticsText(report.Diagnostics)
	for _, want := range []string{
		"family expansion file must contain a non-empty name_prefix",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected diagnostic %q, got:\n%s", want, text)
		}
	}
}

func writeFeatGeneratedHarness(t *testing.T, root string, generatedFiles map[string]string, lock map[string]int) {
	t.Helper()
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "spells"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "CRValue", "ReqSkillMinRanks", "MINATTACKBONUS", "SUCCESSOR", "ALLCLASSESCANUSE", "PreReqEpic", "ReqAction", "TOOLSCATEGORIES"],
  "rows": [
    {"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}}, "ICON": "ife_skfoc", "CRValue": "0.5", "ReqSkillMinRanks": "3"},
    {"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterskillfocus.name", "text": "Greater Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterskillfocus.description", "text": "Choose a skill again."}}, "ICON": "ife_skfoc", "CRValue": "1", "ReqSkillMinRanks": "6"},
    {"id": 6, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:weaponfocus.name", "text": "Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponfocus.description", "text": "Focus on one weapon."}}, "ICON": "ife_wepfoc", "CRValue": "1", "MINATTACKBONUS": "1", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"},
    {"id": 7, "key": "masterfeats:improvedcritical", "LABEL": "ImprovedCritical", "STRREF": {"tlk": {"key": "masterfeats:improvedcritical.name", "text": "Improved Critical"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:improvedcritical.description", "text": "Improved critical threat."}}, "ICON": "ife_impcrit", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"},
    {"id": 8, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "WeaponProficiencyCommoner", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple commoner weapon proficiency."}}, "ICON": "ife_weppro", "CRValue": "0.2", "SUCCESSOR": "9", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1"},
    {"id": 9, "key": "masterfeats:greaterweaponfocus", "LABEL": "GreaterWeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterweaponfocus.name", "text": "Greater Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterweaponfocus.description", "text": "Focus harder on one weapon."}}, "ICON": "ife_gwepfoc", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "0", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4,"masterfeats:greaterskillfocus":5,"masterfeats:weaponfocus":6,"masterfeats:improvedcritical":7,"masterfeats:weaponproficiencycommoner":8,"masterfeats:greaterweaponfocus":9}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 1, "key": "skills:concentration", "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"id": 2, "key": "skills:athletics", "Label": "Athletics", "Name": "271", "Description": "346", "Icon": "isk_athletics", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_ATHLETICS", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:concentration":1,"skills:athletics":2}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["label", "Name", "WeaponFocusFeat", "WeaponImprovedCriticalFeat", "WeaponSpecializationFeat", "EpicWeaponFocusFeat", "EpicWeaponSpecializationFeat", "EpicWeaponOverwhelmingCriticalFeat"],
  "rows": [
    {"id": 1, "key": "baseitems:testclub", "label": "test_club", "Name": {"tlk": {"key": "baseitems:testclub.name", "text": "Test Club"}}, "WeaponFocusFeat": "3003", "WeaponImprovedCriticalFeat": "3004", "WeaponSpecializationFeat": "****", "EpicWeaponFocusFeat": "****", "EpicWeaponSpecializationFeat": "****", "EpicWeaponOverwhelmingCriticalFeat": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:testclub":1}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "base.json"), `{
  "columns": ["Label", "Name", "FeatsTable"],
  "rows": [
    {"id": 7, "key": "racialtypes:beast", "Label": "Beast", "Name": "Beast", "FeatsTable": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "lock.json"), `{"racialtypes:beast":7,"racialtypes:testfolk":31}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "testfolk.json"), `{
  "key": "racialtypes:testfolk",
  "core": {
    "Label": "Testfolk",
    "Name": {"tlk": {"key": "racialtypes:testfolk.name", "text": "Testfolk"}}
  },
  "feat_output": "race_feat_test.2da",
  "feats": [
    {"FeatLabel": "SkillAffinityAthletics", "FeatIndex": {"id": "feat:skillaffinityathletics"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{"output":"spells.2da","columns":["Label"],"rows":[{"id":10,"key":"spells:specialattacks","Label":"SpecialAttacks"}]}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{"spells:specialattacks":10}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": [
    "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
    "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
    "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
    "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
    "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
  ],
  "rows": []
}`+"\n")
	lockJSON, err := json.MarshalIndent(lock, "", "  ")
	if err != nil {
		t.Fatalf("marshal feat lock: %v", err)
	}
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), string(lockJSON)+"\n")
	for name, content := range generatedFiles {
		writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", name), content)
	}
}

func TestSplitFamilyExpansionIdentity(t *testing.T) {
	got := splitFamilyExpansionIdentity("weaponspecialization_club")
	if got.Parent != "weaponspecialization" || got.Child != "club" {
		t.Fatalf("unexpected family identity: %#v", got)
	}
	got = splitFamilyExpansionIdentity("gnome")
	if got.Parent != "gnome" || got.Child != "" {
		t.Fatalf("unexpected standalone family identity: %#v", got)
	}
}

func TestParseRowMetadataSupportsFamilyMetadata(t *testing.T) {
	meta, err := parseRowMetadata(map[string]any{
		"family": map[string]any{
			"parent":   "weaponspecialization",
			"child":    "club",
			"source":   "baseitems:club",
			"template": "masterfeats:weaponspecialization",
		},
	})
	if err != nil {
		t.Fatalf("parseRowMetadata: %v", err)
	}
	family, ok := meta["family"].(map[string]any)
	if !ok {
		t.Fatalf("expected family metadata, got %#v", meta)
	}
	if family["parent"] != "weaponspecialization" || family["child"] != "club" {
		t.Fatalf("unexpected family metadata: %#v", family)
	}
}

func TestPreferredGeneratedFeatKeyReusesLegacyCompactChildToken(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData:       map[string]int{"feat:skillfocus_craftalchemy": 42},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
	}
	got := ctx.preferredGeneratedFeatKey("skillfocus", "craft_alchemy")
	if got != "feat:skillfocus_craftalchemy" {
		t.Fatalf("expected reused compact family key, got %q", got)
	}
}

func TestPreferredGeneratedFeatKeyReusesLegacyTLKStateKey(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData:       map[string]int{},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{"feat:greaterskillfocus_craftalchemy.name": {}},
	}
	got := ctx.preferredGeneratedFeatKey("greaterskillfocus", "craft_alchemy")
	if got != "feat:greaterskillfocus_craftalchemy" {
		t.Fatalf("expected reused tlk-state family key, got %q", got)
	}
}

func TestResolveGeneratedFeatIdentityTranslatesLegacyFamilySourceID(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData:       map[string]int{"feat:epicweaponfocus_club": 619},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
		existingFeat:   map[string]struct{}{"feat:epicweaponfocus_club": {}},
	}
	got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "club", "619")
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err)
	}
	if got != "feat:greaterweaponfocus_club" || !hasID || rowID != 619 {
		t.Fatalf("expected translated greater weapon focus identity at id 619, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
}

func TestResolveGeneratedFeatIdentityMovesExplicitCanonicalRefToLegacyAliasID(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData: map[string]int{
			"feat:greaterweaponfocus_shortspear": 1289,
			"feat:epicweaponfocus_shortspear":    627,
		},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
	}
	got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "shortspear", map[string]any{"id": "feat:greaterweaponfocus_shortspear"})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err)
	}
	if got != "feat:greaterweaponfocus_shortspear" || !hasID || rowID != 627 {
		t.Fatalf("expected explicit canonical ref to move to legacy alias id 627, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
}

func TestResolveGeneratedFeatIdentityUsesLegacyAliasForNoSourceFamily(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData: map[string]int{
			"feat:skillfocus_animalhandling":    1307,
			"feat:skillfocus_animalempathy":     34,
			"feat:greaterskillfocus_appraise":   1316,
			"feat:epicskillfocus_appraise":      588,
			"feat:epicskillfocus_animalempathy": 587,
		},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
	}
	spec := familyExpansionSpec{FamilyKey: "skillfocus"}
	got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity skill focus renamed skill: %v", err)
	}
	if got != "feat:skillfocus_animalhandling" || !hasID || rowID != 34 {
		t.Fatalf("expected skill focus renamed child to take legacy alias id 34, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}

	spec = familyExpansionSpec{FamilyKey: "greaterskillfocus"}
	got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
	}
	if got != "feat:greaterskillfocus_appraise" || !hasID || rowID != 588 {
		t.Fatalf("expected no-source generated family to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
	got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err)
	}
	if got != "feat:greaterskillfocus_animalhandling" || !hasID || rowID != 587 {
		t.Fatalf("expected renamed no-source generated family to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
}

func TestResolveGeneratedFeatIdentityUsesLegacyAliasForUnderscoreNoSourceFamily(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData: map[string]int{
			"feat:greater_skill_focus_appraise":    1316,
			"feat:epic_skill_focus_appraise":       588,
			"feat:epic_skill_focus_animal_empathy": 587,
		},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
		familySpecs: map[string]familyExpansionSpec{
			"greater_skill_focus": {
				FamilyKey:        "greater_skill_focus",
				LegacyFamilyKeys: []string{"epic_skill_focus"},
			},
		},
	}
	spec := familyExpansionSpec{FamilyKey: "greater_skill_focus"}

	got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
	}
	if got != "feat:greater_skill_focus_appraise" || !hasID || rowID != 588 {
		t.Fatalf("expected underscore greater skill focus to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}

	got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animal_handling", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err)
	}
	if got != "feat:greater_skill_focus_animal_handling" || !hasID || rowID != 587 {
		t.Fatalf("expected renamed underscore greater skill focus to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
}

func TestResolveGeneratedFeatIdentityTranslatesLegacyWeaponFamilySourceIDWithUnderscoreKey(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData: map[string]int{
			"feat:epic_weapon_focus_club":          619,
			"feat:epic_weapon_specialization_club": 650,
			"feat:epic_overwhelming_critical_club": 900,
		},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
		existingFeat: map[string]struct{}{
			"feat:epic_weapon_focus_club":          {},
			"feat:epic_weapon_specialization_club": {},
			"feat:epic_overwhelming_critical_club": {},
		},
		familySpecs: map[string]familyExpansionSpec{
			"greater_weapon_focus": {
				FamilyKey:        "greater_weapon_focus",
				LegacyFamilyKeys: []string{"epic_weapon_focus"},
			},
			"greater_weapon_specialization": {
				FamilyKey:        "greater_weapon_specialization",
				LegacyFamilyKeys: []string{"epic_weapon_specialization"},
			},
			"overwhelming_critical": {
				FamilyKey:        "overwhelming_critical",
				LegacyFamilyKeys: []string{"epic_overwhelming_critical"},
			},
		},
	}

	cases := []struct {
		family string
		want   string
		rowID  int
	}{
		{family: "greater_weapon_focus", want: "feat:greater_weapon_focus_club", rowID: 619},
		{family: "greater_weapon_specialization", want: "feat:greater_weapon_specialization_club", rowID: 650},
		{family: "overwhelming_critical", want: "feat:overwhelming_critical_club", rowID: 900},
	}
	for _, tc := range cases {
		got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource(tc.family, "club", fmt.Sprintf("%d", tc.rowID))
		if err != nil {
			t.Fatalf("resolveGeneratedFeatIdentityBySource(%s): %v", tc.family, err)
		}
		if got != tc.want || !hasID || rowID != tc.rowID {
			t.Fatalf("expected %s at id %d, got key=%q id=%d hasID=%v", tc.want, tc.rowID, got, rowID, hasID)
		}
	}
}

func TestResolveGeneratedFeatIdentityUsesConfiguredLegacyFamilyKeys(t *testing.T) {
	ctx := &featGeneratedContext{
		lockData: map[string]int{
			"feat:old_weapon_training_test_club": 4000,
		},
		supplementalID: map[string]int{},
		tlkStateKeys:   map[string]struct{}{},
		familySpecs: map[string]familyExpansionSpec{
			"new_weapon_training": {
				FamilyKey:        "new_weapon_training",
				LegacyFamilyKeys: []string{"old_weapon_training"},
			},
		},
	}
	spec := familyExpansionSpec{FamilyKey: "new_weapon_training"}

	got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "test_club", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
	}
	if got != "feat:new_weapon_training_test_club" || !hasID || rowID != 4000 {
		t.Fatalf("expected configured legacy family to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}

	if !generatedAliasLockForKey(
		"feat:new_weapon_training_test_club",
		"feat:old_weapon_training_test_club",
		generatedFamilyAliases{"new_weapon_training": []string{"old_weapon_training"}},
	) {
		t.Fatal("expected configured legacy family lock to be movable")
	}

	got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(familyExpansionSpec{FamilyKey: "newweapontraining"}, "test_club", map[string]any{})
	if err != nil {
		t.Fatalf("resolveGeneratedFeatIdentity compact family key: %v", err)
	}
	if got != "feat:newweapontraining_test_club" || !hasID || rowID != 4000 {
		t.Fatalf("expected normalized configured legacy lookup to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID)
	}
}

func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"greater_skill_focus.json": `{
  "family": "greater_skill_focus",
  "family_key": "greaterskillfocus",
  "template": "masterfeats:greaterskillfocus",
  "name_prefix": "Greater Skill Focus",
  "label_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "legacy_family_keys": ["epicskillfocus"],
  "child_ref_field": "REQSKILL",
  "child_source": {"dataset":"skills","predicate":"accessible"},
  "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
}` + "\n",
	}, map[string]int{
		"feat:greaterskillfocus_concentration": 1317,
		"feat:epicskillfocus_concentration":    589,
	})

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
		t.Fatalf("expected greater skill focus to move to legacy alias id 589, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
	if err != nil {
		t.Fatalf("read feat lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"feat:greaterskillfocus_concentration": 589`) {
		t.Fatalf("expected canonical lock to move to legacy alias id, got:\n%s", lockText)
	}
	if strings.Contains(lockText, "epicskillfocus_concentration") {
		t.Fatalf("expected stale epic skill focus lock to be pruned, got:\n%s", lockText)
	}
}

func TestBuildFamilyExpansionMovesUnderscoreCanonicalLockToUnderscoreLegacyAliasID(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"greater_skill_focus.json": `{
  "family": "greater_skill_focus",
  "family_key": "greater_skill_focus",
  "template": "masterfeats:greaterskillfocus",
  "name_prefix": "Greater Skill Focus",
  "label_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "legacy_family_keys": ["epic_skill_focus"],
  "child_ref_field": "REQSKILL",
  "child_source": {"dataset":"skills","predicate":"accessible"},
  "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
}` + "\n",
	}, map[string]int{
		"feat:greater_skill_focus_concentration": 1317,
		"feat:epic_skill_focus_concentration":    589,
	})

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
		t.Fatalf("expected underscore greater skill focus to move to legacy alias id 589, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
	if err != nil {
		t.Fatalf("read feat lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) {
		t.Fatalf("expected canonical underscore key to own legacy row id 589, got:\n%s", lockText)
	}
	if strings.Contains(lockText, "epic_skill_focus_concentration") {
		t.Fatalf("expected stale underscore epic skill focus lock to be pruned, got:\n%s", lockText)
	}
}

func TestBuildFamilyExpansionRegeneratesLegacyAliasIDWithoutFeatLock(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"greater_skill_focus.json": `{
  "family": "greater_skill_focus",
  "family_key": "greater_skill_focus",
  "template": "masterfeats:greaterskillfocus",
  "name_prefix": "Greater Skill Focus",
  "label_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "legacy_family_keys": ["epic_skill_focus"],
  "child_ref_field": "REQSKILL",
  "child_source": {"dataset":"skills","predicate":"accessible"},
  "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
}` + "\n",
	}, map[string]int{})
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": [
    "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
    "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
    "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
    "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
    "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
  ],
  "rows": [
    {
      "id": 589,
      "key": "feat:epic_skill_focus_concentration",
      "LABEL": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION",
      "REQSKILL": 1,
      "MASTERFEAT": 5,
      "Constant": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
		t.Fatalf("expected empty lockfile rebuild to preserve legacy row id 589, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
	if err != nil {
		t.Fatalf("read feat lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) {
		t.Fatalf("expected regenerated lock to assign greater key to row 589, got:\n%s", lockText)
	}
	if strings.Contains(lockText, "epic_skill_focus_concentration") {
		t.Fatalf("expected regenerated lock to prune legacy epic key, got:\n%s", lockText)
	}
}

func TestParseFamilyExpansionRejectsInvalidLegacyFamilyKeys(t *testing.T) {
	base := map[string]any{
		"family":       "greater_skill_focus",
		"family_key":   "greater_skill_focus",
		"template":     "masterfeats:greaterskillfocus",
		"child_source": map[string]any{"dataset": "skills", "predicate": "accessible"},
	}
	cases := []struct {
		name   string
		keys   []any
		errMsg string
	}{
		{
			name:   "self alias",
			keys:   []any{"greater_skill_focus"},
			errMsg: "legacy_family_keys must not include family_key",
		},
		{
			name:   "normalized duplicate",
			keys:   []any{"epicskillfocus", "epic_skill_focus"},
			errMsg: "legacy_family_keys contains duplicate-equivalent keys",
		},
	}
	for _, tc := range cases {
		obj := map[string]any{}
		for key, value := range base {
			obj[key] = value
		}
		obj["legacy_family_keys"] = tc.keys
		_, err := parseFamilyExpansionSpec(tc.name+".json", obj)
		if err == nil || !strings.Contains(err.Error(), tc.errMsg) {
			t.Fatalf("%s: expected %q error, got %v", tc.name, tc.errMsg, err)
		}
	}
}

func TestValidateBaseitemsWeaponFeatColumnCompletenessRejectsPartialCoreFamilies(t *testing.T) {
	ctx := &featGeneratedContext{
		rowsByDataset: map[string]map[string]map[string]any{
			"baseitems": {
				"baseitems:testblade": {
					"key":                                "baseitems:testblade",
					"WeaponFocusFeat":                    1000,
					"WeaponSpecializationFeat":           1001,
					"WeaponImprovedCriticalFeat":         1002,
					"EpicWeaponFocusFeat":                1003,
					"EpicWeaponSpecializationFeat":       1004,
					"EpicWeaponOverwhelmingCriticalFeat": "****",
				},
				"baseitems:prop": {
					"key":        "baseitems:prop",
					"WeaponType": 2,
				},
			},
		},
	}
	report := ValidationReport{}

	validateBaseitemsWeaponFeatColumnCompleteness("generated", ctx, &report)

	if !report.HasErrors() {
		t.Fatalf("expected partial core weapon feat columns to be rejected")
	}
	if len(report.Diagnostics) != 1 {
		t.Fatalf("expected one diagnostic, got %#v", report.Diagnostics)
	}
	if !strings.Contains(report.Diagnostics[0].Message, "baseitems:testblade") ||
		!strings.Contains(report.Diagnostics[0].Message, "EpicWeaponOverwhelmingCriticalFeat") {
		t.Fatalf("unexpected diagnostic: %#v", report.Diagnostics[0])
	}
}

func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
  "family": "anything",
  "family_key": "skillfocus",
  "template": "masterfeats:skillfocus",
  "name_prefix": "Skill Focus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "template_fields": ["DESCRIPTION", "ICON"],
  "child_source": {"dataset":"skills","predicate":"accessible"}
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "weapon_focus.json"), `{
  "family": "anything_else",
  "family_key": "weaponfocus",
  "template": "masterfeats:weaponfocus",
  "name_prefix": "Weapon Focus",
  "label_prefix": "FEAT_WEAPON_FOCUS",
  "constant_prefix": "FEAT_WEAPON_FOCUS",
  "template_fields": ["DESCRIPTION", "ICON"],
  "identity_source": "child_source_value",
  "child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"}
}`+"\n")
	entries := map[string]tlkStateMapping{
		"feat:skillfocus_craftalchemy.name":               {ID: 10, Retired: true},
		"feat:weaponproficiencycommoner_club.description": {ID: 11, Retired: true},
		"feat:weaponfocus_club.name":                      {ID: 12, Retired: false},
		"feat:specialattacks.name":                        {ID: 13, Retired: true},
		"masterfeats:skillfocus.name":                     {ID: 14, Retired: true},
	}

	pruneRetiredGeneratedFeatTLKEntries(filepath.Join(root, "topdata"), entries)

	if _, ok := entries["feat:skillfocus_craftalchemy.name"]; ok {
		t.Fatal("expected retired generated skill focus TLK key to be pruned")
	}
	if _, ok := entries["feat:weaponproficiencycommoner_club.description"]; !ok {
		t.Fatal("expected module-authored proficiency TLK key to be preserved")
	}
	if _, ok := entries["feat:weaponfocus_club.name"]; !ok {
		t.Fatal("expected active generated feat TLK key to be preserved")
	}
	if _, ok := entries["feat:specialattacks.name"]; !ok {
		t.Fatal("expected manual specialattacks TLK key to be preserved")
	}
	if _, ok := entries["masterfeats:skillfocus.name"]; !ok {
		t.Fatal("expected non-feat TLK key to be preserved")
	}
}

func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json":         `{"family":"skill_family_from_data","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_skfoc_override"}}}` + "\n",
		"greater_skill_focus.json": `{"family":"greater_skill_family_from_data","family_key":"greaterskillfocus","template":"masterfeats:greaterskillfocus","name_prefix":"Greater Skill Focus","label_prefix":"FEAT_GREATER_SKILL_FOCUS","constant_prefix":"FEAT_GREATER_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
		"weapon_focus.json":        `{"family":"custom_weapon_mastery","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"},"overrides":{"baseitems:testclub":{"ICON":"ife_wepfoc_override"}}}` + "\n",
		"improved_critical.json":   `{"family":"custom_critical_chain","family_key":"improvedcritical","template":"masterfeats:improvedcritical","name_prefix":"Improved Critical","label_prefix":"FEAT_IMPROVED_CRITICAL","constant_prefix":"FEAT_IMPROVED_CRITICAL","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","auto_prereq_fields":{"PREREQFEAT1":"weaponfocus"},"child_source":{"dataset":"baseitems","column":"WeaponImprovedCriticalFeat"}}` + "\n",
	}, map[string]int{"feat:skillfocus_concentration": 173, "feat:greaterskillfocus_concentration": 589, "feat:skillaffinityathletics": 1253, "feat:weaponfocus_testclub": 3003, "feat:improvedcritical_testclub": 3004, "feat:weaponproficiencycommoner_club": 1123, "feat:specialattacks": 1187, "feat:improvedfeint": 1194})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:specialattacks":{"LABEL":"FEAT_SPECIAL_ATTACKS","FEAT":{"tlk":{"key":"feat:specialattacks.name","text":"Special Attacks"}},"DESCRIPTION":{"tlk":{"key":"feat:specialattacks.description","text":"Access special attacks."}},"ICON":"ife_specialatk","CATEGORY":"22","HostileFeat":"1","MAXCR":"0","SPELLID":{"id":"spells:specialattacks"},"Constant":"FEAT_SPECIAL_ATTACKS","TOOLSCATEGORIES":"2","PreReqEpic":"0","ReqAction":"0"},"feat:improvedfeint":{"LABEL":"FEAT_IMPROVED_FEINT","FEAT":{"tlk":{"key":"feat:improvedfeint.name","text":"Improved Feint"}},"DESCRIPTION":{"tlk":{"key":"feat:improvedfeint.description","text":"Improve your feint."}},"ICON":"ife_feint","PREREQFEAT1":{"id":"feat:specialattacks"},"Constant":"FEAT_IMPROVED_FEINT","TOOLSCATEGORIES":{"field":"TOOLSCATEGORIES","ref":"feat:specialattacks"},"PreReqEpic":"0","ReqAction":"1"}}}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency", "standalone.json"), `{"entries":{"feat:towershieldproficiency":{"LABEL":"FEAT_TOWER_SHIELD_PROFICIENCY","FEAT":{"tlk":{"key":"feat:towershieldproficiency.name","text":"Tower Shield Proficiency"}},"DESCRIPTION":{"tlk":{"key":"feat:towershieldproficiency.description","text":"Use tower shields."}},"ICON":"ife_towshprof","PREREQFEAT1":{"id":"feat:shieldproficiency"},"Constant":"FEAT_TOWER_SHIELD_PROFICIENCY","ReqAction":"1","PreReqEpic":"0"}},"overrides":[{"key":"feat:shieldproficiency","id":32,"DESCRIPTION":{"tlk":{"key":"feat:shieldproficiency.description","text":"Use shields."}}},{"key":"feat:weaponproficiencycommoner_club","id":1123,"LABEL":"CommonerWeaponProficiencyCLUB","Constant":"FEAT_WEAPON_PROFICIENCY_CLUB","FEAT":{"tlk":{"key":"feat:weaponproficiencycommoner_club.name","text":"Commoner Weapon Proficiency (club)"}},"DESCRIPTION":{"field":"DESCRIPTION","ref":"masterfeats:weaponproficiencycommoner"},"ICON":{"field":"ICON","ref":"masterfeats:weaponproficiencycommoner"},"MASTERFEAT":{"id":"masterfeats:weaponproficiencycommoner"},"SUCCESSOR":{"field":"SUCCESSOR","ref":"masterfeats:weaponproficiencycommoner"},"ALLCLASSESCANUSE":{"field":"ALLCLASSESCANUSE","ref":"masterfeats:weaponproficiencycommoner"},"PreReqEpic":{"field":"PreReqEpic","ref":"masterfeats:weaponproficiencycommoner"},"ReqAction":{"field":"ReqAction","ref":"masterfeats:weaponproficiencycommoner"},"CRValue":{"field":"CRValue","ref":"masterfeats:weaponproficiencycommoner"}}]}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	for _, want := range []string{"FEAT_SKILL_FOCUS_CONCENTRATION", "FEAT_GREATER_SKILL_FOCUS_CONCENTRATION", "FEAT_SKILL_AFFINITY_ATHLETICS", "FEAT_WEAPON_FOCUS_TEST_CLUB", "FEAT_IMPROVED_CRITICAL_TEST_CLUB", "CommonerWeaponProficiencyCLUB", "FEAT_SPECIAL_ATTACKS", "FEAT_IMPROVED_FEINT", "FEAT_TOWER_SHIELD_PROFICIENCY", "ife_wepfoc_override"} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected generated feat output to contain %q, got:\n%s", want, text)
		}
	}
}

func TestBuildFamilyExpansionUsesAuthoredSpecForCoverageAndBaselines(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json":  `{"family":"whatever_the_author_wants","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
		"weapon_focus.json": `{"family":"another_authored_name","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"9","ALLCLASSESCANUSE":"0"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"}}` + "\n",
	}, map[string]int{"feat:skillfocus_concentration": 173, "feat:skillfocus_athletics": 174, "feat:weaponfocus_testclub": 3003})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons", "testclub.json"), `{"overrides":[{"key":"feat:weaponfocus_testclub","id":3003,"LABEL":"FEAT_WEAPON_FOCUS_TEST_CLUB","Constant":"FEAT_WEAPON_FOCUS_TEST_CLUB","FEAT":{"tlk":{"key":"feat:weaponfocus_testclub.name","text":"Weapon Focus (Test Club)"}}}]}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)

	for _, want := range []string{
		"FEAT_SKILL_FOCUS_CONCENTRATION",
		"FEAT_SKILL_FOCUS_ATHLETICS",
		"FEAT_WEAPON_FOCUS_TEST_CLUB",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected feat output to contain %q, got:\n%s", want, text)
		}
	}

	type rowData map[string]string
	parse2DA := func(data string) map[string]rowData {
		lines := strings.Split(data, "\n")
		header := strings.Split(lines[2], "\t")
		rows := map[string]rowData{}
		for _, line := range lines[3:] {
			if strings.TrimSpace(line) == "" {
				continue
			}
			parts := strings.Split(line, "\t")
			if len(parts) < len(header)+1 {
				continue
			}
			row := rowData{}
			for i, col := range header {
				row[col] = parts[i+1]
			}
			rows[parts[0]] = row
		}
		return rows
	}

	featRows := parse2DA(text)
	masterRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
	if err != nil {
		t.Fatalf("read masterfeats.2da: %v", err)
	}
	masterRows := parse2DA(string(masterRaw))

	assertFieldEquals := func(featID, masterID, field string) {
		got := featRows[featID][field]
		want := masterRows[masterID][field]
		if got != want {
			t.Fatalf("expected %s field %s=%q from masterfeat %s, got %q", featID, field, want, masterID, got)
		}
	}

	assertFieldEquals("173", "4", "DESCRIPTION")
	assertFieldEquals("173", "4", "CRValue")
	assertFieldEquals("173", "4", "ReqSkillMinRanks")
	assertFieldEquals("173", "4", "ALLCLASSESCANUSE")
	assertFieldEquals("173", "4", "PreReqEpic")
	assertFieldEquals("173", "4", "ReqAction")
	if got := featRows["173"]["REQSKILL"]; got != "1" {
		t.Fatalf("expected skill child ref to bind to REQSKILL=1, got %q", got)
	}
	if got := featRows["173"]["TOOLSCATEGORIES"]; got != "2" {
		t.Fatalf("expected skill default TOOLSCATEGORIES=2, got %q", got)
	}

	if got := featRows["3003"]["TOOLSCATEGORIES"]; got != "9" {
		t.Fatalf("expected weapon shared default TOOLSCATEGORIES=9, got %q", got)
	}
	if got := featRows["3003"]["ALLCLASSESCANUSE"]; got != "0" {
		t.Fatalf("expected weapon shared default ALLCLASSESCANUSE=0 on existing row, got %q", got)
	}
}

func TestBuildFamilyExpansionCanApplySharedAuthoredFieldsAfterModules(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","default_fields":{"DESCRIPTION":{"tlk":{"key":"feat:skillfocus.shared.description","text":"Family-owned description."}},"CRValue":"0.5","ReqSkillMinRanks":"1","ALLCLASSESCANUSE":"1","TOOLSCATEGORIES":"6","PreReqEpic":"0","ReqAction":"1"},"apply_after_modules":true,"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_family_owned_conc"}}}` + "\n",
	}, map[string]int{"feat:skillfocus_concentration": 173})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat", "focus.json"), `{"overrides":[{"key":"feat:skillfocus_concentration","id":173,"LABEL":"FEAT_SKILL_FOCUS_CONCENTRATION","FEAT":{"tlk":{"key":"feat:skillfocus_concentration.name","text":"Skill Focus (Concentration)"}},"DESCRIPTION":{"tlk":{"key":"feat:skillfocus_concentration.description","text":"Wrong module description."}},"ICON":"ife_wrong_module","CRValue":"9.9","MASTERFEAT":{"id":"masterfeats:skillfocus"},"REQSKILL":{"id":"skills:concentration"},"ReqSkillMinRanks":"99","TOOLSCATEGORIES":"2","ALLCLASSESCANUSE":"0","PreReqEpic":"1","ReqAction":"0","Constant":"FEAT_SKILL_FOCUS_CONCENTRATION"}]}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	lines := strings.Split(string(got), "\n")
	header := strings.Split(lines[2], "\t")
	var row map[string]string
	for _, line := range lines[3:] {
		if !strings.HasPrefix(line, "173\t") {
			continue
		}
		parts := strings.Split(line, "\t")
		row = map[string]string{}
		for i, col := range header {
			row[col] = parts[i+1]
		}
		break
	}
	if row == nil {
		t.Fatal("expected feat row 173 to exist")
	}
	if got := row["ICON"]; got != "ife_family_owned_conc" {
		t.Fatalf("expected family override ICON after modules, got %q", got)
	}
	if got := row["CRValue"]; got != "0.5" {
		t.Fatalf("expected family shared CRValue after modules, got %q", got)
	}
	if got := row["ReqSkillMinRanks"]; got != "1" {
		t.Fatalf("expected family shared ReqSkillMinRanks after modules, got %q", got)
	}
	if got := row["ALLCLASSESCANUSE"]; got != "1" {
		t.Fatalf("expected family shared ALLCLASSESCANUSE after modules, got %q", got)
	}
	if got := row["TOOLSCATEGORIES"]; got != "6" {
		t.Fatalf("expected family shared TOOLSCATEGORIES after modules, got %q", got)
	}
	if got := row["PreReqEpic"]; got != "0" {
		t.Fatalf("expected family shared PreReqEpic after modules, got %q", got)
	}
	if got := row["ReqAction"]; got != "1" {
		t.Fatalf("expected family shared ReqAction after modules, got %q", got)
	}
}

func TestBuildGeneratedFeatSpecialAttacksRequiresSpellsTarget(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{}, map[string]int{"feat:specialattacks": 1187})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:specialattacks":{"LABEL":"FEAT_SPECIAL_ATTACKS","FEAT":{"tlk":{"key":"feat:specialattacks.name","text":"Special Attacks"}},"DESCRIPTION":{"tlk":{"key":"feat:specialattacks.description","text":"Access special attacks."}},"ICON":"ife_specialatk","SPELLID":{"id":"spells:missing"},"Constant":"FEAT_SPECIAL_ATTACKS"}}}`+"\n")
	_, err := BuildNative(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), "spells:missing") {
		t.Fatalf("expected missing spells target error, got %v", err)
	}
}

func TestBuildGeneratedFeatSpecialAttacksRequiresSelfRefTarget(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{}, map[string]int{"feat:improvedfeint": 1194})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:improvedfeint":{"LABEL":"FEAT_IMPROVED_FEINT","FEAT":{"tlk":{"key":"feat:improvedfeint.name","text":"Improved Feint"}},"DESCRIPTION":{"tlk":{"key":"feat:improvedfeint.description","text":"Improve your feint."}},"ICON":"ife_feint","PREREQFEAT1":{"id":"feat:specialattacks"},"Constant":"FEAT_IMPROVED_FEINT"}}}`+"\n")
	_, err := BuildNative(testProject(root), nil)
	if err == nil || !strings.Contains(err.Error(), "feat:specialattacks") {
		t.Fatalf("expected missing feat self-ref error, got %v", err)
	}
}

func TestBuildGeneratedFeatRequiresMasterfeatAndSkillTargets(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:missing","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
	}, map[string]int{"feat:skillfocus_concentration": 173})
	_, err := BuildNative(testProject(root), nil)
	if err == nil {
		t.Fatal("expected missing dependency error")
	}
	if !strings.Contains(err.Error(), "masterfeats:missing") {
		t.Fatalf("expected error containing missing masterfeat target, got %v", err)
	}
}

func TestDiscoverNativeOutputCatalogSkipsCompareDisabledDataset(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "data", "feat"))
	writeFile(t, filepath.Join(root, "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": ["LABEL", "FEAT", "DESCRIPTION"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")

	catalog, err := discoverNativeOutputCatalog(filepath.Join(root, "data"))
	if err != nil {
		t.Fatalf("discoverNativeOutputCatalog: %v", err)
	}
	if _, ok := catalog["feat.2da"]; ok {
		t.Fatalf("expected compare-disabled feat.2da to be excluded from coverage catalog")
	}
}

func TestNormalizeProjectImportsLegacyMasterfeats(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	mkdirAll(t, filepath.Join(root, "reference", "data", "feat", "masterfeats"))
	writeFile(t, filepath.Join(root, "reference", "data", "feat", "masterfeats", "masterfeats.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "SUCCESSOR"],
  "rows": [
    {"id": 0, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"},
    {"key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": {"tlk": "masterfeats:skillfocus.name"}, "DESCRIPTION": {"tlk": "masterfeats:skillfocus.description"}, "ICON": "ife_skfoc"},
    {"id": 2, "LABEL": "DevastatingCrit", "STRREF": "83482", "DESCRIPTION": "3909", "ICON": "ife_X2DevCrit"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "feat", "masterfeats", "lock.json"), `{
  "masterfeats:weaponfocus": 0,
  "masterfeats:skillfocus": 1
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference", "tlk", "modules", "feat", "masterfeats"))
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "masterfeats:skillfocus.name": 100,
  "masterfeats:skillfocus.description": 101
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "modules", "feat", "masterfeats", "skillfocus.json"), `{
  "entries": {
    "masterfeats:skillfocus": {
      "name": {"text": "Skill Focus"},
      "description": {"text": "Choose a skill."}
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 2 {
		t.Fatalf("expected imported masterfeats files, got %#v", result)
	}

	raw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "masterfeats", "base.json"))
	if err != nil {
		t.Fatalf("read imported masterfeats: %v", err)
	}
	text := string(raw)
	for _, want := range []string{
		`"STRREF": "6490"`,
		`"DESCRIPTION": "436"`,
		`"key": "masterfeats:skillfocus.name"`,
		`"text": "Skill Focus"`,
		`"key": "masterfeats:skillfocus.description"`,
		`"text": "Choose a skill."`,
		`"id": 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected imported masterfeats content %q, got:\n%s", want, text)
		}
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"))
	if err != nil {
		t.Fatalf("read imported lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"masterfeats:skillfocus": 1`) {
		t.Fatalf("expected imported lock data, got:\n%s", string(lockRaw))
	}

	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	if !strings.Contains(string(stateRaw), `"masterfeats:skillfocus.name"`) || !strings.Contains(string(stateRaw), `"id": 100`) {
		t.Fatalf("expected seeded masterfeats tlk state, got:\n%s", string(stateRaw))
	}
}

func TestBuildSupportsCanonicalMasterfeats(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "SUCCESSOR"],
  "rows": [
    {"id": 0, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"},
    {"id": 1, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}}, "ICON": "ife_skfoc"},
    {"id": 2, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "CommonerWeaponProficiency", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple weapon training."}}, "SUCCESSOR": {"id": "feat:weaponproficiencysimple"}, "ICON": "ife_weppro_sim"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:weaponfocus": 0,
  "masterfeats:skillfocus": 1,
  "masterfeats:weaponproficiencycommoner": 2
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:weaponproficiencysimple":7}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 7, "key": "feat:weaponproficiencysimple", "LABEL": "WEAPON_PROFICIENCY_SIMPLE"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
	if err != nil {
		t.Fatalf("read masterfeats.2da: %v", err)
	}
	want := "2DA V2.0\n\nLABEL\tSTRREF\tDESCRIPTION\tICON\tSUCCESSOR\n0\tWeaponFocus\t6490\t436\tife_wepfoc\t****\n1\tSkillFocus\t16777216\t16777217\tife_skfoc\t****\n2\tCommonerWeaponProficiency\t16777218\t16777219\tife_weppro_sim\t7\n"
	if string(got) != want {
		t.Fatalf("unexpected masterfeats.2da output:\n%s", string(got))
	}
}

func TestNormalizeProjectImportsLegacyCreaturespeed(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "base.json"), `{
  "output": "creaturespeed.2da",
  "columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
  "rows": [
    {"key": "creaturespeed:pcmovement", "id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
    {"key": "creaturespeed:normal", "id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "1.75", "RUNRATE": "3.50"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "lock.json"), `{"creaturespeed:normal":4}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules", "ovr_mobspeed.json"), `{
  "overrides": [
    {"key": "creaturespeed:normal", "id": 4, "WALKRATE": "2.00", "RUNRATE": "4.00"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 1 {
		t.Fatalf("expected creaturespeed file to be imported, got %#v", result)
	}

	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "creaturespeed", "creaturespeed.json"))
	if err != nil {
		t.Fatalf("read creaturespeed table: %v", err)
	}
	text := string(baseRaw)
	for _, want := range []string{`"output": "creaturespeed.2da"`, `"Label": "Normal"`, `"WALKRATE": "2.00"`, `"RUNRATE": "4.00"`} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected imported creaturespeed content %q, got:\n%s", want, text)
		}
	}
	if strings.Contains(text, `"key": "creaturespeed:normal"`) {
		t.Fatalf("did not expect creaturespeed keys in plain-table import, got:\n%s", text)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "creaturespeed", "lock.json")); !os.IsNotExist(err) {
		t.Fatalf("expected no creaturespeed lock file, got err=%v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "creaturespeed", "modules", "ovr_mobspeed.json")); !os.IsNotExist(err) {
		t.Fatalf("expected no creaturespeed module file, got err=%v", err)
	}
}

func TestBuildSupportsCanonicalCreaturespeed(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "creaturespeed"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "creaturespeed", "creaturespeed.json"), `{
  "output": "creaturespeed.2da",
  "columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
  "rows": [
    {"id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
    {"id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "2.00", "RUNRATE": "4.00"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "creaturespeed.2da"))
	if err != nil {
		t.Fatalf("read creaturespeed.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "Label\tName\t2DAName\tWALKRATE\tRUNRATE\n") ||
		!strings.Contains(text, "0\tPC_Movement\t****\tPLAYER\t2.00\t4.00\n") ||
		!strings.Contains(text, "4\tNormal\t2322\tNORM\t2.00\t4.00\n") {
		t.Fatalf("unexpected creaturespeed.2da output:\n%s", string(got))
	}
}

func TestNormalizeProjectImportsLegacyArmor(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "armor", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "base.json"), `{
  "columns": ["ACBONUS", "DEXBONUS", "ACCHECK", "ARCANEFAILURE%", "WEIGHT", "COST", "DESCRIPTIONS", "BASEITEMSTATREF"],
  "rows": [
    {"id": 0, "ACBONUS": "0", "DEXBONUS": "100", "ACCHECK": "0", "ARCANEFAILURE%": "0", "WEIGHT": "10", "COST": "1", "DESCRIPTIONS": "1727", "BASEITEMSTATREF": "5411"},
    {"id": 8, "ACBONUS": "8", "DEXBONUS": "1", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "500", "COST": "1500", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "5441"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "lock.json"), `{"armor:ac9":9}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "modules", "add_12ac.json"), `{
  "entries": {
    "armor:ac9": {"id": 9, "ACBONUS": "9", "DEXBONUS": "0", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "650", "COST": "2400", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "modules", "ovr_statref.json"), `{
  "overrides": [
    {"id": 0, "BASEITEMSTATREF": "****"},
    {"id": 8, "BASEITEMSTATREF": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 1 {
		t.Fatalf("expected armor file to be imported, got %#v", result)
	}

	tableRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "armor", "armor.json"))
	if err != nil {
		t.Fatalf("read armor table: %v", err)
	}
	text := string(tableRaw)
	for _, want := range []string{`"output": "armor.2da"`, `"id": 0`, `"BASEITEMSTATREF": "****"`, `"id": 9`, `"ACBONUS": "9"`} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected imported armor content %q, got:\n%s", want, text)
		}
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "armor", "lock.json")); !os.IsNotExist(err) {
		t.Fatalf("expected no armor lock file, got err=%v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "armor", "modules")); !os.IsNotExist(err) {
		t.Fatalf("expected no armor modules dir, got err=%v", err)
	}
}

func TestBuildSupportsCanonicalArmor(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "armor"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "armor", "armor.json"), `{
  "output": "armor.2da",
  "columns": ["ACBONUS", "DEXBONUS", "ACCHECK", "ARCANEFAILURE%", "WEIGHT", "COST", "DESCRIPTIONS", "BASEITEMSTATREF"],
  "rows": [
    {"id": 0, "ACBONUS": "0", "DEXBONUS": "100", "ACCHECK": "0", "ARCANEFAILURE%": "0", "WEIGHT": "10", "COST": "1", "DESCRIPTIONS": "1727", "BASEITEMSTATREF": "****"},
    {"id": 9, "ACBONUS": "9", "DEXBONUS": "0", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "650", "COST": "2400", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "armor.2da"))
	if err != nil {
		t.Fatalf("read armor.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "ACBONUS\tDEXBONUS\tACCHECK\tARCANEFAILURE%\tWEIGHT\tCOST\tDESCRIPTIONS\tBASEITEMSTATREF\n") ||
		!strings.Contains(text, "0\t0\t100\t0\t0\t10\t1\t1727\t****\n") ||
		!strings.Contains(text, "9\t9\t0\t-8\t45\t650\t2400\t1736\t****\n") {
		t.Fatalf("unexpected armor.2da output:\n%s", text)
	}
}

func TestBuildCanonicalArmorMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "armor", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "armor.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("ACBONUS\tDEXBONUS\tACCHECK\tARCANEFAILURE%\tWEIGHT\tCOST\tDESCRIPTIONS\tBASEITEMSTATREF\n")
    f.write("0\t0\t100\t0\t0\t10\t1\t1727\t****\n")
    f.write("1\t1\t8\t0\t5\t50\t5\t1728\t****\n")
    f.write("2\t2\t6\t0\t10\t100\t10\t1729\t****\n")
    f.write("3\t3\t4\t-1\t20\t150\t15\t1730\t****\n")
    f.write("4\t4\t4\t-2\t20\t300\t100\t1731\t****\n")
    f.write("5\t5\t2\t-5\t30\t400\t150\t1732\t****\n")
    f.write("6\t6\t1\t-7\t40\t450\t200\t1733\t****\n")
    f.write("7\t7\t1\t-7\t40\t500\t600\t1734\t****\n")
    f.write("8\t8\t1\t-8\t45\t500\t1500\t1736\t****\n")
    f.write("9\t9\t0\t-8\t45\t650\t2400\t1736\t****\n")
    f.write("10\t10\t0\t-9\t50\t800\t5000\t1736\t****\n")
    f.write("11\t11\t0\t-10\t55\t800\t7500\t1736\t****\n")
    f.write("12\t12\t0\t-11\t60\t1250\t10000\t1736\t****\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "base.json"), `{
  "columns": ["ACBONUS", "DEXBONUS", "ACCHECK", "ARCANEFAILURE%", "WEIGHT", "COST", "DESCRIPTIONS", "BASEITEMSTATREF"],
  "rows": [
    {"id": 0, "ACBONUS": "0", "DEXBONUS": "100", "ACCHECK": "0", "ARCANEFAILURE%": "0", "WEIGHT": "10", "COST": "1", "DESCRIPTIONS": "1727", "BASEITEMSTATREF": "5411"},
    {"id": 1, "ACBONUS": "1", "DEXBONUS": "8", "ACCHECK": "0", "ARCANEFAILURE%": "5", "WEIGHT": "50", "COST": "5", "DESCRIPTIONS": "1728", "BASEITEMSTATREF": "5432"},
    {"id": 2, "ACBONUS": "2", "DEXBONUS": "6", "ACCHECK": "0", "ARCANEFAILURE%": "10", "WEIGHT": "100", "COST": "10", "DESCRIPTIONS": "1729", "BASEITEMSTATREF": "5435"},
    {"id": 3, "ACBONUS": "3", "DEXBONUS": "4", "ACCHECK": "-1", "ARCANEFAILURE%": "20", "WEIGHT": "150", "COST": "15", "DESCRIPTIONS": "1730", "BASEITEMSTATREF": "5436"},
    {"id": 4, "ACBONUS": "4", "DEXBONUS": "4", "ACCHECK": "-2", "ARCANEFAILURE%": "20", "WEIGHT": "300", "COST": "100", "DESCRIPTIONS": "1731", "BASEITEMSTATREF": "5437"},
    {"id": 5, "ACBONUS": "5", "DEXBONUS": "2", "ACCHECK": "-5", "ARCANEFAILURE%": "30", "WEIGHT": "400", "COST": "150", "DESCRIPTIONS": "1732", "BASEITEMSTATREF": "5438"},
    {"id": 6, "ACBONUS": "6", "DEXBONUS": "1", "ACCHECK": "-7", "ARCANEFAILURE%": "40", "WEIGHT": "450", "COST": "200", "DESCRIPTIONS": "1733", "BASEITEMSTATREF": "5439"},
    {"id": 7, "ACBONUS": "7", "DEXBONUS": "1", "ACCHECK": "-7", "ARCANEFAILURE%": "40", "WEIGHT": "500", "COST": "600", "DESCRIPTIONS": "1734", "BASEITEMSTATREF": "5440"},
    {"id": 8, "ACBONUS": "8", "DEXBONUS": "1", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "500", "COST": "1500", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "5441"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "lock.json"), `{"armor:ac9":9,"armor:ac10":10,"armor:ac11":11,"armor:ac12":12}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "modules", "add_12ac.json"), `{
  "entries": {
    "armor:ac9": {"id": 9, "ACBONUS": "9", "DEXBONUS": "0", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "650", "COST": "2400", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"},
    "armor:ac10": {"id": 10, "ACBONUS": "10", "DEXBONUS": "0", "ACCHECK": "-9", "ARCANEFAILURE%": "50", "WEIGHT": "800", "COST": "5000", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"},
    "armor:ac11": {"id": 11, "ACBONUS": "11", "DEXBONUS": "0", "ACCHECK": "-10", "ARCANEFAILURE%": "55", "WEIGHT": "800", "COST": "7500", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"},
    "armor:ac12": {"id": 12, "ACBONUS": "12", "DEXBONUS": "0", "ACCHECK": "-11", "ARCANEFAILURE%": "60", "WEIGHT": "1250", "COST": "10000", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "armor", "modules", "ovr_statref.json"), `{
  "overrides": [
    {"id": 0, "BASEITEMSTATREF": "****"},
    {"id": 1, "BASEITEMSTATREF": "****"},
    {"id": 2, "BASEITEMSTATREF": "****"},
    {"id": 3, "BASEITEMSTATREF": "****"},
    {"id": 4, "BASEITEMSTATREF": "****"},
    {"id": 5, "BASEITEMSTATREF": "****"},
    {"id": 6, "BASEITEMSTATREF": "****"},
    {"id": 7, "BASEITEMSTATREF": "****"},
    {"id": 8, "BASEITEMSTATREF": "****"}
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "armor.2da"))
	if err != nil {
		t.Fatalf("read native armor.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "armor.2da"))
	if err != nil {
		t.Fatalf("read reference armor.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("armor output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyDamagetypesRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	for _, part := range []string{"core", "groups", "hitvisual"} {
		mkdirAll(t, filepath.Join(root, "reference", "data", "damagetypes", part, "modules"))
	}
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "base.json"), `{
  "output": "damagetypes.2da",
  "columns": ["Label", "CharsheetStrref", "DamageTypeGroup", "DamageRangedProjectile"],
  "rows": [
    {"id": 0, "Label": "Bludgeoning", "CharsheetStrref": "58345", "DamageTypeGroup": "0", "DamageRangedProjectile": "0"},
    {"id": 12, "Label": "Base", "CharsheetStrref": "58301", "DamageTypeGroup": "0", "DamageRangedProjectile": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "base.json"), `{
  "output": "damagetypegroups.2da",
  "columns": ["Label", "FeedbackStrref", "ColorR", "ColorG", "ColorB"],
  "rows": [
    {"id": 0, "Label": "Physical", "FeedbackStrref": "5594", "ColorR": "255", "ColorG": "102", "ColorB": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "base.json"), `{
  "output": "damagehitvisual.2da",
  "columns": ["Label", "VisualEffectID", "RangedEffectID"],
  "rows": [
    {"id": 0, "Label": "Bludgeoning", "VisualEffectID": "****", "RangedEffectID": "****"},
    {"id": 12, "Label": "Base", "VisualEffectID": "****", "RangedEffectID": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "lock.json"), `{"damagetypes:psychic":13}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "lock.json"), `{"damagetypegroups:psychic":10}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "lock.json"), `{"damagehitvisual:psychic":13}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "modules", "add_eos.json"), `{
  "entries": {
    "damagetypes:psychic": {"Label": "PsychicDamage", "CharsheetStrref": "70001", "DamageTypeGroup": "10", "DamageRangedProjectile": "0"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "modules", "add_eos.json"), `{
  "entries": {
    "damagetypegroups:psychic": {"Label": "Psychic", "FeedbackStrref": "70011", "ColorR": "225", "ColorG": "117", "ColorB": "255"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "modules", "add_eos.json"), `{
  "entries": {
    "damagehitvisual:psychic": {"Label": "PsychicDamage", "VisualEffectID": "202", "RangedEffectID": "****"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 2 {
		t.Fatalf("expected damagetypes registry files to be imported, got %#v", result)
	}

	typesRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "damagetypes", "registry", "types.json"))
	if err != nil {
		t.Fatalf("read damagetypes registry: %v", err)
	}
	text := string(typesRaw)
	for _, want := range []string{`"key": "damagetype:bludgeoning"`, `"group_label": "Physical"`, `"key": "damagetype:psychic"`, `"feedback_strref": "70011"`} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected imported damagetypes content %q, got:\n%s", want, text)
		}
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "damagetypes", "registry", "lock.json"))
	if err != nil {
		t.Fatalf("read damagetypes lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"damagetype:psychic": 13`) {
		t.Fatalf("expected imported damagetypes lock ids, got:\n%s", string(lockRaw))
	}
	for _, oldPath := range []string{
		filepath.Join(root, "topdata", "data", "damagetypes", "core"),
		filepath.Join(root, "topdata", "data", "damagetypes", "groups"),
		filepath.Join(root, "topdata", "data", "damagetypes", "hitvisual"),
	} {
		if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
			t.Fatalf("expected no legacy canonical damagetypes path %s, got err=%v", oldPath, err)
		}
	}
}

func TestBuildSupportsCanonicalDamagetypesRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry", "types.json"), `{
  "rows": [
    {"key": "damagetype:bludgeoning", "id": 0, "label": "Bludgeoning", "charsheet_strref": "58345", "damage_type_group": "0", "damage_ranged_projectile": "0", "group_label": "Physical", "feedback_strref": "5594", "color_r": "255", "color_g": "102", "color_b": "0", "visual_effect_id": "****", "ranged_effect_id": "****"},
    {"key": "damagetype:psychic", "id": 13, "label": "PsychicDamage", "charsheet_strref": "70001", "damage_type_group": "10", "damage_ranged_projectile": "0", "group_label": "Psychic", "feedback_strref": "70011", "color_r": "225", "color_g": "117", "color_b": "255", "visual_effect_id": "202", "ranged_effect_id": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry", "lock.json"), `{"damagetype:bludgeoning":0,"damagetype:psychic":13}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	coreRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagetypes.2da"))
	if err != nil {
		t.Fatalf("read damagetypes.2da: %v", err)
	}
	groupRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagetypegroups.2da"))
	if err != nil {
		t.Fatalf("read damagetypegroups.2da: %v", err)
	}
	hitRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagehitvisual.2da"))
	if err != nil {
		t.Fatalf("read damagehitvisual.2da: %v", err)
	}
	if !strings.Contains(string(coreRaw), "0\tBludgeoning\t58345\t0\t0\n") || !strings.Contains(string(coreRaw), "13\tPsychicDamage\t70001\t10\t0\n") {
		t.Fatalf("unexpected damagetypes.2da output:\n%s", string(coreRaw))
	}
	if !strings.Contains(string(groupRaw), "0\tPhysical\t5594\t255\t102\t0\n") || !strings.Contains(string(groupRaw), "10\tPsychic\t70011\t225\t117\t255\n") {
		t.Fatalf("unexpected damagetypegroups.2da output:\n%s", string(groupRaw))
	}
	if !strings.Contains(string(hitRaw), "0\tBludgeoning\t****\t****\n") || !strings.Contains(string(hitRaw), "13\tPsychicDamage\t202\t****\n") {
		t.Fatalf("unexpected damagehitvisual.2da output:\n%s", string(hitRaw))
	}
}

func TestBuildCanonicalDamagetypesRegistryMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	for _, part := range []string{"core", "groups", "hitvisual"} {
		mkdirAll(t, filepath.Join(root, "reference", "data", "damagetypes", part, "modules"))
	}
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "damagetypes.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tCharsheetStrref\tDamageTypeGroup\tDamageRangedProjectile\n")
    f.write("0\tBludgeoning\t58345\t0\t0\n")
    f.write("1\tPiercing\t58341\t0\t0\n")
    f.write("13\tPsychicDamage\t70001\t10\t0\n")
with open(os.path.join(out, "damagetypegroups.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tFeedbackStrref\tColorR\tColorG\tColorB\n")
    f.write("0\tPhysical\t5594\t255\t102\t0\n")
    f.write("1\tMagical\t5593\t204\t119\t255\n")
    f.write("10\tPsychic\t70011\t225\t117\t255\n")
with open(os.path.join(out, "damagehitvisual.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tVisualEffectID\tRangedEffectID\n")
    f.write("0\tBludgeoning\t****\t****\n")
    f.write("1\tPiercing\t****\t****\n")
    f.write("13\tPsychicDamage\t202\t****\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "base.json"), `{
  "output": "damagetypes.2da",
  "columns": ["Label", "CharsheetStrref", "DamageTypeGroup", "DamageRangedProjectile"],
  "rows": [
    {"id": 0, "Label": "Bludgeoning", "CharsheetStrref": "58345", "DamageTypeGroup": "0", "DamageRangedProjectile": "0"},
    {"id": 1, "Label": "Piercing", "CharsheetStrref": "58341", "DamageTypeGroup": "0", "DamageRangedProjectile": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "base.json"), `{
  "output": "damagetypegroups.2da",
  "columns": ["Label", "FeedbackStrref", "ColorR", "ColorG", "ColorB"],
  "rows": [
    {"id": 0, "Label": "Physical", "FeedbackStrref": "5594", "ColorR": "255", "ColorG": "102", "ColorB": "0"},
    {"id": 1, "Label": "Magical", "FeedbackStrref": "5593", "ColorR": "204", "ColorG": "119", "ColorB": "255"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "base.json"), `{
  "output": "damagehitvisual.2da",
  "columns": ["Label", "VisualEffectID", "RangedEffectID"],
  "rows": [
    {"id": 0, "Label": "Bludgeoning", "VisualEffectID": "****", "RangedEffectID": "****"},
    {"id": 1, "Label": "Piercing", "VisualEffectID": "****", "RangedEffectID": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "lock.json"), `{"damagetypes:psychic":13}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "lock.json"), `{"damagetypegroups:psychic":10}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "lock.json"), `{"damagehitvisual:psychic":13}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "core", "modules", "add_eos.json"), `{"entries":{"damagetypes:psychic":{"Label":"PsychicDamage","CharsheetStrref":"70001","DamageTypeGroup":"10","DamageRangedProjectile":"0"}}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "groups", "modules", "add_eos.json"), `{"entries":{"damagetypegroups:psychic":{"Label":"Psychic","FeedbackStrref":"70011","ColorR":"225","ColorG":"117","ColorB":"255"}}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "damagetypes", "hitvisual", "modules", "add_eos.json"), `{"entries":{"damagehitvisual:psychic":{"Label":"PsychicDamage","VisualEffectID":"202","RangedEffectID":"****"}}}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	for _, name := range []string{"damagetypes.2da", "damagetypegroups.2da", "damagehitvisual.2da"} {
		nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, name))
		if err != nil {
			t.Fatalf("read native %s: %v", name, err)
		}
		referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, name))
		if err != nil {
			t.Fatalf("read reference %s: %v", name, err)
		}
		if string(nativeBytes) != string(referenceBytes) {
			t.Fatalf("%s output mismatch\nnative:\n%s\nreference:\n%s", name, string(nativeBytes), string(referenceBytes))
		}
	}
}

func TestBuildCanonicalCreaturespeedMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "creaturespeed.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tName\t2DAName\tWALKRATE\tRUNRATE\n")
    f.write("0\tPC_Movement\t****\tPLAYER\t2.00\t4.00\n")
    f.write("1\t****\t****\t****\t****\t****\n")
    f.write("2\t****\t****\t****\t****\t****\n")
    f.write("3\t****\t****\t****\t****\t****\n")
    f.write("4\tNormal\t2322\tNORM\t2.00\t4.00\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "base.json"), `{
  "output": "creaturespeed.2da",
  "columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
  "rows": [
    {"key": "creaturespeed:pcmovement", "id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
    {"key": "creaturespeed:normal", "id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "1.75", "RUNRATE": "3.50"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "lock.json"), `{"creaturespeed:normal":4}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules", "ovr_mobspeed.json"), `{
  "overrides": [
    {"key": "creaturespeed:normal", "id": 4, "WALKRATE": "2.00", "RUNRATE": "4.00"}
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "creaturespeed.2da"))
	if err != nil {
		t.Fatalf("read native creaturespeed.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "creaturespeed.2da"))
	if err != nil {
		t.Fatalf("read reference creaturespeed.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("creaturespeed output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacySkyboxes(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "skyboxes", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "base.json"), `{
  "output": "skyboxes.2da",
  "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
  "rows": [
    {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"},
    {"id": 1, "LABEL": "Grass_Clear", "STRING_REF": "****", "CYCLICAL": "1", "DAWN": "Skyda_001", "DAY": "Sky_001", "DUSK": "Skyd_001", "NIGHT": "Skyn_001"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "lock.json"), `{
  "festerpot:id_7": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "modules", "add_fpskies.json"), `{
  "entries": {
    "festerpot:id_7": {
      "LABEL": "Mountains",
      "CYCLICAL": 1,
      "DAWN": "CCS_01a",
      "DAY": "CCS_01b",
      "DUSK": "CCS_01c",
      "NIGHT": "CCS_01d"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 3 {
		t.Fatalf("expected skyboxes files to be imported, got %#v", result)
	}

	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "skyboxes", "base.json"),
		filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"),
		filepath.Join(root, "topdata", "data", "skyboxes", "modules", "add_fpskies.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported skyboxes path %s: %v", path, err)
		}
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"))
	if err != nil {
		t.Fatalf("read skyboxes lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"festerpot:id_7": 7`) {
		t.Fatalf("expected imported skyboxes lock IDs, got:\n%s", string(lockRaw))
	}
}

func TestBuildSupportsCanonicalSkyboxes(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "base.json"), `{
  "output": "skyboxes.2da",
  "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
  "rows": [
    {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"},
    {"id": 1, "LABEL": "Grass_Clear", "STRING_REF": "****", "CYCLICAL": "1", "DAWN": "Skyda_001", "DAY": "Sky_001", "DUSK": "Skyd_001", "NIGHT": "Skyn_001"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"), `{
  "festerpot:id_7": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules", "add_fpskies.json"), `{
  "entries": {
    "festerpot:id_7": {
      "LABEL": "Mountains",
      "CYCLICAL": 1,
      "DAWN": "CCS_01a",
      "DAY": "CCS_01b",
      "DUSK": "CCS_01c",
      "NIGHT": "CCS_01d"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skyboxes.2da"))
	if err != nil {
		t.Fatalf("read skyboxes.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tSTRING_REF\tCYCLICAL\tDAWN\tDAY\tDUSK\tNIGHT\n") ||
		!strings.Contains(text, "0\t(None)\t****\t****\t****\t****\t****\t****\n") ||
		!strings.Contains(text, "1\tGrass_Clear\t****\t1\tSkyda_001\tSky_001\tSkyd_001\tSkyn_001\n") ||
		!strings.Contains(text, "7\tMountains\t****\t1\tCCS_01a\tCCS_01b\tCCS_01c\tCCS_01d\n") {
		t.Fatalf("unexpected skyboxes.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalSkyboxesMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "skyboxes", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "skyboxes.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tSTRING_REF\tCYCLICAL\tDAWN\tDAY\tDUSK\tNIGHT\n")
    f.write("0\t(None)\t****\t****\t****\t****\t****\t****\n")
    f.write("1\tGrass_Clear\t****\t1\tSkyda_001\tSky_001\tSkyd_001\tSkyn_001\n")
    for i in range(2, 7):
        f.write(f"{i}\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("7\tMountains\t****\t1\tCCS_01a\tCCS_01b\tCCS_01c\tCCS_01d\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "base.json"), `{
  "output": "skyboxes.2da",
  "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
  "rows": [
    {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"},
    {"id": 1, "LABEL": "Grass_Clear", "STRING_REF": "****", "CYCLICAL": "1", "DAWN": "Skyda_001", "DAY": "Sky_001", "DUSK": "Skyd_001", "NIGHT": "Skyn_001"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "lock.json"), `{
  "festerpot:id_7": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "modules", "add_fpskies.json"), `{
  "entries": {
    "festerpot:id_7": {
      "LABEL": "Mountains",
      "CYCLICAL": 1,
      "DAWN": "CCS_01a",
      "DAY": "CCS_01b",
      "DUSK": "CCS_01c",
      "NIGHT": "CCS_01d"
    }
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "skyboxes.2da"))
	if err != nil {
		t.Fatalf("read native skyboxes.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "skyboxes.2da"))
	if err != nil {
		t.Fatalf("read reference skyboxes.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("skyboxes output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestCompareNativeCoverageCountsPlainAndBaseDatasets(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "creaturespeed"))
	writeFile(t, filepath.Join(root, "topdata", "data", "creaturespeed", "creaturespeed.json"), `{
  "output": "creaturespeed.2da",
  "columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
  "rows": [
    {"id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
    {"id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "2.00", "RUNRATE": "4.00"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "base.json"), `{
  "output": "skyboxes.2da",
  "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
  "rows": [
    {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"), `{
  "festerpot:id_7": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules", "add_fpskies.json"), `{
  "entries": {
    "festerpot:id_7": {
      "LABEL": "Mountains",
      "CYCLICAL": 1,
      "DAWN": "CCS_01a",
      "DAY": "CCS_01b",
      "DUSK": "CCS_01c",
      "NIGHT": "CCS_01d"
    }
  }
}`+"\n")

	mkdirAll(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "base.json"), `{
  "output": "creaturespeed.2da",
  "columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
  "rows": [
    {"key": "creaturespeed:pcmovement", "id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
    {"key": "creaturespeed:normal", "id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "1.75", "RUNRATE": "3.50"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "lock.json"), `{"creaturespeed:normal":4}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules", "ovr_mobspeed.json"), `{
  "overrides": [
    {"key": "creaturespeed:normal", "id": 4, "WALKRATE": "2.00", "RUNRATE": "4.00"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "skyboxes", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "base.json"), `{
  "output": "skyboxes.2da",
  "columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
  "rows": [
    {"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "lock.json"), `{
  "festerpot:id_7": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "modules", "add_fpskies.json"), `{
  "entries": {
    "festerpot:id_7": {
      "LABEL": "Mountains",
      "CYCLICAL": 1,
      "DAWN": "CCS_01a",
      "DAY": "CCS_01b",
      "DUSK": "CCS_01c",
      "NIGHT": "CCS_01d"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	pass, notYetCanonical, mismatch, err := compareNativeCoverage(testProject(root), nativeResult.Output2DADir, nil)
	if err != nil {
		t.Fatalf("compareNativeCoverage failed: %v", err)
	}
	if pass != 2 || notYetCanonical != 0 || mismatch != 0 {
		t.Fatalf("unexpected coverage counts: pass=%d notYetCanonical=%d mismatch=%d", pass, notYetCanonical, mismatch)
	}
}

func TestNormalizeProjectImportsLegacyVFXPersistent(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "vfx_persistent", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "base.json"), `{
  "output": "vfx_persistent.2da",
  "columns": ["LABEL", "SHAPE", "RADIUS"],
  "rows": [
    {"id": 0, "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
    {"id": 46, "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "lock.json"), `{
  "vfx_persistent:blankradius5": 47,
  "vfx_persistent:blankradius10": 48,
  "vfx_persistent:blankradius15": 49,
  "vfx_persistent:blankradius20": 50,
  "vfx_persistent:blankradius25": 51,
  "vfx_persistent:blankradius30": 52
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "modules", "freeformaoes.json"), `{
  "entries": {
    "vfx_persistent:blankradius5": {"LABEL": "VFX_PER_BLANK_RADIUS_5", "SHAPE": "C", "RADIUS": "5"},
    "vfx_persistent:blankradius10": {"LABEL": "VFX_PER_BLANK_RADIUS_10", "SHAPE": "C", "RADIUS": "5"},
    "vfx_persistent:blankradius15": {"LABEL": "VFX_PER_BLANK_RADIUS_15", "SHAPE": "C", "RADIUS": "15"},
    "vfx_persistent:blankradius20": {"LABEL": "VFX_PER_BLANK_RADIUS_20", "SHAPE": "C", "RADIUS": "20"},
    "vfx_persistent:blankradius25": {"LABEL": "VFX_PER_BLANK_RADIUS_25", "SHAPE": "C", "RADIUS": "25"},
    "vfx_persistent:blankradius30": {"LABEL": "VFX_PER_BLANK_RADIUS_30", "SHAPE": "C", "RADIUS": "30"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 2 {
		t.Fatalf("expected vfx_persistent files to be imported, got %#v", result)
	}

	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "vfx_persistent", "base.json"),
		filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"),
		filepath.Join(root, "topdata", "data", "vfx_persistent", "modules", "freeformaoes.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported vfx_persistent path %s: %v", path, err)
		}
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"))
	if err != nil {
		t.Fatalf("read vfx_persistent lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"vfx_persistent:vfx_per_fogacid": 0`) ||
		!strings.Contains(lockText, `"vfx_persistent:vfx_mob_nightmare_smoke": 46`) ||
		!strings.Contains(lockText, `"vfx_persistent:blankradius5": 47`) ||
		!strings.Contains(lockText, `"vfx_persistent:blankradius10": 48`) ||
		!strings.Contains(lockText, `"vfx_persistent:blankradius30": 52`) {
		t.Fatalf("expected imported vfx_persistent lock IDs, got:\n%s", lockText)
	}

	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "vfx_persistent", "base.json"))
	if err != nil {
		t.Fatalf("read vfx_persistent base: %v", err)
	}
	baseText := string(baseRaw)
	if !strings.Contains(baseText, `"key": "vfx_persistent:vfx_per_fogacid"`) || !strings.Contains(baseText, `"key": "vfx_persistent:vfx_mob_nightmare_smoke"`) {
		t.Fatalf("expected imported vfx_persistent keys, got:\n%s", baseText)
	}
}

func TestNormalizeProjectRefreshesLegacyVFXPersistentLockFromSnapshot(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "vfx_persistent", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "base.json"), `{
  "output": "vfx_persistent.2da",
  "columns": ["LABEL", "SHAPE", "RADIUS"],
  "rows": [
    {"id": 0, "key": "vfx_persistent:vfx_per_fogacid", "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
    {"id": 46, "key": "vfx_persistent:vfx_mob_nightmare_smoke", "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"), `{
  "vfx_persistent:blankradius10": 47,
  "vfx_persistent:blankradius15": 48,
  "vfx_persistent:blankradius20": 49,
  "vfx_persistent:blankradius25": 50,
  "vfx_persistent:blankradius30": 51,
  "vfx_persistent:blankradius5": 52
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "modules", "freeformaoes.json"), `{"entries": {}}`+"\n")

	writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "vfx_persistent", "base.json"), `{
  "output": "vfx_persistent.2da",
  "columns": ["LABEL", "SHAPE", "RADIUS"],
  "rows": [
    {"id": 0, "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
    {"id": 46, "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "vfx_persistent", "lock.json"), `{
  "vfx_persistent:blankradius5": 47,
  "vfx_persistent:blankradius10": 48,
  "vfx_persistent:blankradius15": 49,
  "vfx_persistent:blankradius20": 50,
  "vfx_persistent:blankradius25": 51,
  "vfx_persistent:blankradius30": 52
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "vfx_persistent", "modules", "freeformaoes.json"), `{
  "entries": {
    "vfx_persistent:blankradius5": {"LABEL": "VFX_PER_BLANK_RADIUS_5", "SHAPE": "C", "RADIUS": "5"}
  }
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := NormalizeProject(p)
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 3 {
		t.Fatalf("expected snapshot-backed vfx_persistent refresh, got %#v", result)
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"))
	if err != nil {
		t.Fatalf("read vfx_persistent lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"vfx_persistent:blankradius5": 47`) ||
		!strings.Contains(lockText, `"vfx_persistent:blankradius10": 48`) ||
		!strings.Contains(lockText, `"vfx_persistent:blankradius30": 52`) {
		t.Fatalf("expected refreshed snapshot lock IDs, got:\n%s", lockText)
	}
}

func TestBuildSupportsCanonicalVFXPersistent(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "vfx_persistent"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "base.json"), `{
  "output": "vfx_persistent.2da",
  "columns": ["LABEL", "SHAPE", "RADIUS"],
  "rows": [
    {"id": 0, "key": "vfx_persistent:vfx_per_fogacid", "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
    {"id": 46, "key": "vfx_persistent:vfx_mob_nightmare_smoke", "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"), `{
  "vfx_persistent:vfx_per_fogacid": 0,
  "vfx_persistent:vfx_mob_nightmare_smoke": 46
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "vfx_persistent.2da"))
	if err != nil {
		t.Fatalf("read vfx_persistent.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tSHAPE\tRADIUS\n") ||
		!strings.Contains(text, "0\tVFX_PER_FOGACID\tC\t5\n") ||
		!strings.Contains(text, "46\tVFX_MOB_NIGHTMARE_SMOKE\tC\t1.5\n") {
		t.Fatalf("unexpected vfx_persistent.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalVFXPersistentMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "vfx_persistent", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "vfx_persistent.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tSHAPE\tRADIUS\n")
    f.write("0\tVFX_PER_FOGACID\tC\t5\n")
    for i in range(1, 46):
        f.write(f"{i}\t****\t****\t****\n")
    f.write("46\tVFX_MOB_NIGHTMARE_SMOKE\tC\t1.5\n")
    f.write("47\tVFX_PER_BLANK_RADIUS_5\tC\t5\n")
    f.write("48\tVFX_PER_BLANK_RADIUS_10\tC\t5\n")
    f.write("49\tVFX_PER_BLANK_RADIUS_15\tC\t15\n")
    f.write("50\tVFX_PER_BLANK_RADIUS_20\tC\t20\n")
    f.write("51\tVFX_PER_BLANK_RADIUS_25\tC\t25\n")
    f.write("52\tVFX_PER_BLANK_RADIUS_30\tC\t30\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "base.json"), `{
  "output": "vfx_persistent.2da",
  "columns": ["LABEL", "SHAPE", "RADIUS"],
  "rows": [
    {"id": 0, "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
    {"id": 46, "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "lock.json"), `{
  "vfx_persistent:blankradius5": 47,
  "vfx_persistent:blankradius10": 48,
  "vfx_persistent:blankradius15": 49,
  "vfx_persistent:blankradius20": 50,
  "vfx_persistent:blankradius25": 51,
  "vfx_persistent:blankradius30": 52
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "vfx_persistent", "modules", "freeformaoes.json"), `{
  "entries": {
    "vfx_persistent:blankradius5": {"LABEL": "VFX_PER_BLANK_RADIUS_5", "SHAPE": "C", "RADIUS": "5"},
    "vfx_persistent:blankradius10": {"LABEL": "VFX_PER_BLANK_RADIUS_10", "SHAPE": "C", "RADIUS": "5"},
    "vfx_persistent:blankradius15": {"LABEL": "VFX_PER_BLANK_RADIUS_15", "SHAPE": "C", "RADIUS": "15"},
    "vfx_persistent:blankradius20": {"LABEL": "VFX_PER_BLANK_RADIUS_20", "SHAPE": "C", "RADIUS": "20"},
    "vfx_persistent:blankradius25": {"LABEL": "VFX_PER_BLANK_RADIUS_25", "SHAPE": "C", "RADIUS": "25"},
    "vfx_persistent:blankradius30": {"LABEL": "VFX_PER_BLANK_RADIUS_30", "SHAPE": "C", "RADIUS": "30"}
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "vfx_persistent.2da"))
	if err != nil {
		t.Fatalf("read native vfx_persistent.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "vfx_persistent.2da"))
	if err != nil {
		t.Fatalf("read reference vfx_persistent.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("vfx_persistent output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestBuildNativeRemovesStaleTopdataOutputs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 0, "LABEL": "TEST"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "2da"))
	writeFile(t, filepath.Join(root, "topdata", "assets", "2da", "stale.2da"), "stale\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	if got, want := result.Output2DADir, filepath.Join(root, ".cache", "2da"); got != want {
		t.Fatalf("unexpected cached 2da output dir: got %q want %q", got, want)
	}
	if _, err := os.Stat(filepath.Join(result.Output2DADir, "stale.2da")); !os.IsNotExist(err) {
		t.Fatalf("expected stale legacy topdata output to be ignored, stat err=%v", err)
	}
}

func TestBuildNativeWritesCompiledOutputsToCache(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 0, "LABEL": "TEST"}
  ]
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	if got, want := result.Output2DADir, filepath.Join(root, ".cache", "2da"); got != want {
		t.Fatalf("unexpected 2da output dir: got %q want %q", got, want)
	}
	if got, want := result.OutputTLKDir, filepath.Join(root, "build"); got != want {
		t.Fatalf("unexpected tlk output dir: got %q want %q", got, want)
	}
	if _, err := os.Stat(filepath.Join(result.Output2DADir, "repadjust.2da")); err != nil {
		t.Fatalf("expected compiled 2da output: %v", err)
	}
	if _, err := os.Stat(filepath.Join(result.OutputTLKDir, defaultTLKName)); err != nil {
		t.Fatalf("expected compiled tlk output: %v", err)
	}
}

func TestValidateAndBuildTopdataDoNotRequireReferenceBuilder(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 0, "LABEL": "TEST"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""

	report := ValidateProject(p)
	if report.HasErrors() {
		t.Fatalf("expected validate to succeed without reference builder, got %#v", report.Diagnostics)
	}
	if strings.Contains(diagnosticsText(report.Diagnostics), "reference_builder") {
		t.Fatalf("did not expect reference_builder validation diagnostic, got %#v", report.Diagnostics)
	}
	if _, err := BuildNative(p, nil); err != nil {
		t.Fatalf("BuildNative failed without reference builder: %v", err)
	}
}

func TestCompareReferenceFailsClearlyWithoutReferenceBuilder(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 0, "LABEL": "TEST"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	if _, err := BuildNative(p, nil); err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	compare, err := CompareReference(p, nil)
	if err != nil {
		t.Fatalf("CompareReference failed: %v", err)
	}
	if compare.Mode != "native" || compare.NativeMismatch != 0 || compare.NativePass != 1 {
		t.Fatalf("unexpected compare result without reference builder: %#v", compare)
	}
}

func TestNormalizeProjectImportsLegacyProgfx(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "progfx"))
	writeFile(t, filepath.Join(root, "reference", "data", "progfx", "base.json"), `{
  "output": "progfx.2da",
  "columns": ["Label", "Type", "Param1", "Param2", "Param3", "Param4", "Param5", "Param6", "Param7", "Param8"],
  "rows": [
    {"id": 0, "Label": "****", "Type": "****", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"},
    {"id": 1300, "Label": "FreezeAnimations", "Type": "13", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "progfx", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 2 {
		t.Fatalf("expected progfx files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "progfx", "base.json"),
		filepath.Join(root, "topdata", "data", "progfx", "lock.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported progfx path %s: %v", path, err)
		}
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "progfx", "lock.json"))
	if err != nil {
		t.Fatalf("read progfx lock: %v", err)
	}
	if strings.TrimSpace(string(lockRaw)) != "{}" {
		t.Fatalf("expected empty imported progfx lock, got:\n%s", string(lockRaw))
	}
}

func TestBuildSupportsCanonicalProgfx(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "progfx"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "progfx", "base.json"), `{
  "output": "progfx.2da",
  "columns": ["Label", "Type", "Param1", "Param2", "Param3", "Param4", "Param5", "Param6", "Param7", "Param8"],
  "rows": [
    {"id": 0, "Label": "****", "Type": "****", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"},
    {"id": 1300, "Label": "FreezeAnimations", "Type": "13", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "progfx", "lock.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "progfx.2da"))
	if err != nil {
		t.Fatalf("read progfx.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "Label\tType\tParam1\tParam2\tParam3\tParam4\tParam5\tParam6\tParam7\tParam8\n") ||
		!strings.Contains(text, "0\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n") ||
		!strings.Contains(text, "1300\tFreezeAnimations\t13\t****\t****\t****\t****\t****\t****\t****\t****\n") {
		t.Fatalf("unexpected progfx.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalProgfxMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "progfx"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "progfx.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tType\tParam1\tParam2\tParam3\tParam4\tParam5\tParam6\tParam7\tParam8\n")
    f.write("0\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n")
    for i in range(1, 1300):
        f.write(f"{i}\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("1300\tFreezeAnimations\t13\t****\t****\t****\t****\t****\t****\t****\t****\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "progfx", "base.json"), `{
  "output": "progfx.2da",
  "columns": ["Label", "Type", "Param1", "Param2", "Param3", "Param4", "Param5", "Param6", "Param7", "Param8"],
  "rows": [
    {"id": 0, "Label": "****", "Type": "****", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"},
    {"id": 1300, "Label": "FreezeAnimations", "Type": "13", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "progfx", "lock.json"), "{}\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "progfx.2da"))
	if err != nil {
		t.Fatalf("read native progfx.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "progfx.2da"))
	if err != nil {
		t.Fatalf("read reference progfx.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("progfx output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyRepadjust(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "reference", "data", "repadjust", "base.json"), `{
  "columns": ["LABEL", "PERSONALREP", "FACTIONREP", "WITFRIA", "WITFRIB", "WITFRIC", "WITNEUA", "WITNEUB", "WITNEUC", "WITENEA", "WITENEB", "WITENEC"],
  "rows": [
    {"id": 0, "LABEL": "Attack", "PERSONALREP": "-100", "FACTIONREP": "-5", "WITFRIA": "-100", "WITFRIB": "-100", "WITFRIC": "0", "WITNEUA": "-5", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"},
    {"id": 3, "LABEL": "Help", "PERSONALREP": "0", "FACTIONREP": "0", "WITFRIA": "0", "WITFRIB": "0", "WITFRIC": "0", "WITNEUA": "0", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "repadjust", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 2 {
		t.Fatalf("expected repadjust files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "repadjust", "base.json"),
		filepath.Join(root, "topdata", "data", "repadjust", "lock.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported repadjust path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "repadjust", "base.json"))
	if err != nil {
		t.Fatalf("read repadjust base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "repadjust.2da"`) {
		t.Fatalf("expected imported repadjust output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "repadjust", "lock.json"))
	if err != nil {
		t.Fatalf("read repadjust lock: %v", err)
	}
	if strings.TrimSpace(string(lockRaw)) != "{}" {
		t.Fatalf("expected empty imported repadjust lock, got:\n%s", string(lockRaw))
	}
}

func TestBuildSupportsCanonicalRepadjust(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL", "PERSONALREP", "FACTIONREP", "WITFRIA", "WITFRIB", "WITFRIC", "WITNEUA", "WITNEUB", "WITNEUC", "WITENEA", "WITENEB", "WITENEC"],
  "rows": [
    {"id": 0, "LABEL": "Attack", "PERSONALREP": "-100", "FACTIONREP": "-5", "WITFRIA": "-100", "WITFRIB": "-100", "WITFRIC": "0", "WITNEUA": "-5", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"},
    {"id": 3, "LABEL": "Help", "PERSONALREP": "0", "FACTIONREP": "0", "WITFRIA": "0", "WITFRIB": "0", "WITFRIC": "0", "WITNEUA": "0", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "repadjust.2da"))
	if err != nil {
		t.Fatalf("read repadjust.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tPERSONALREP\tFACTIONREP\tWITFRIA\tWITFRIB\tWITFRIC\tWITNEUA\tWITNEUB\tWITNEUC\tWITENEA\tWITENEB\tWITENEC\n") ||
		!strings.Contains(text, "0\tAttack\t-100\t-5\t-100\t-100\t0\t-5\t0\t0\t0\t0\t0\n") ||
		!strings.Contains(text, "3\tHelp\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\n") {
		t.Fatalf("unexpected repadjust.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalRepadjustMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "repadjust.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tPERSONALREP\tFACTIONREP\tWITFRIA\tWITFRIB\tWITFRIC\tWITNEUA\tWITNEUB\tWITNEUC\tWITENEA\tWITENEB\tWITENEC\n")
    f.write("0\tAttack\t-100\t-5\t-100\t-100\t0\t-5\t0\t0\t0\t0\t0\n")
    f.write("1\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("2\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("3\tHelp\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["LABEL", "PERSONALREP", "FACTIONREP", "WITFRIA", "WITFRIB", "WITFRIC", "WITNEUA", "WITNEUB", "WITNEUC", "WITENEA", "WITENEB", "WITENEC"],
  "rows": [
    {"id": 0, "LABEL": "Attack", "PERSONALREP": "-100", "FACTIONREP": "-5", "WITFRIA": "-100", "WITFRIB": "-100", "WITFRIC": "0", "WITNEUA": "-5", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"},
    {"id": 3, "LABEL": "Help", "PERSONALREP": "0", "FACTIONREP": "0", "WITFRIA": "0", "WITFRIB": "0", "WITFRIC": "0", "WITNEUA": "0", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "repadjust", "lock.json"), "{}\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "repadjust.2da"))
	if err != nil {
		t.Fatalf("read native repadjust.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "repadjust.2da"))
	if err != nil {
		t.Fatalf("read reference repadjust.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("repadjust output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyWingmodel(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "wingmodel", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "base.json"), `{
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "wingmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Angel", "MODEL": "c_angel", "ENVMAP": "default"},
    {"key": "wingmodel:greatsword", "id": 89, "LABEL": "Greatsword", "MODEL": "c_greatsword", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "lock.json"), `{
  "wings:armoredangel": 90,
  "wings:fallenangel": 91,
  "wings:mephisto": 92,
  "wingmodel:demon": 1,
  "wingmodel:angel": 2
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "modules", "add.json"), `{
  "entries": {
    "wings:armoredangel": {
      "LABEL": "Armored Angel",
      "MODEL": "c_armoredangel",
      "ENVMAP": "default"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "modules", "ovr_wingprefixes.json"), `{
  "overrides": [
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Wings: Angel"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 4 {
		t.Fatalf("expected wingmodel files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "wingmodel", "base.json"),
		filepath.Join(root, "topdata", "data", "wingmodel", "lock.json"),
		filepath.Join(root, "topdata", "data", "wingmodel", "modules", "add.json"),
		filepath.Join(root, "topdata", "data", "wingmodel", "modules", "ovr_wingprefixes.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported wingmodel path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "wingmodel", "base.json"))
	if err != nil {
		t.Fatalf("read wingmodel base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "wingmodel.2da"`) {
		t.Fatalf("expected imported wingmodel output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "wingmodel", "lock.json"))
	if err != nil {
		t.Fatalf("read wingmodel lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"wings:mephisto": 92`) || !strings.Contains(lockText, `"wingmodel:angel": 2`) {
		t.Fatalf("expected imported wingmodel lock IDs, got:\n%s", lockText)
	}
}

func TestBuildSupportsCanonicalWingmodel(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "base.json"), `{
  "output": "wingmodel.2da",
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "wingmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Angel", "MODEL": "c_angel", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "lock.json"), `{
  "wings:armoredangel": 90,
  "wingmodel:angel": 2
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules", "add.json"), `{
  "entries": {
    "wings:armoredangel": {
      "LABEL": "Armored Angel",
      "MODEL": "c_armoredangel",
      "ENVMAP": "default"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules", "ovr_wingprefixes.json"), `{
  "overrides": [
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Wings: Angel"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "wingmodel.2da"))
	if err != nil {
		t.Fatalf("read wingmodel.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tMODEL\tENVMAP\n") ||
		!strings.Contains(text, "0\t(None)\t****\t****\n") ||
		!strings.Contains(text, "2\t\"Wings: Angel\"\tc_angel\tdefault\n") ||
		!strings.Contains(text, "90\t\"Armored Angel\"\tc_armoredangel\tdefault\n") {
		t.Fatalf("unexpected wingmodel.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalWingmodelMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "wingmodel", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "wingmodel.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tMODEL\tENVMAP\n")
    f.write("0\t(None)\t****\t****\n")
    f.write("1\t****\t****\t****\n")
    f.write("2\tWings: Angel\tc_angel\tdefault\n")
    for i in range(3, 90):
        f.write(f"{i}\t****\t****\t****\n")
    f.write("90\tArmored Angel\tc_armoredangel\tdefault\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "base.json"), `{
  "output": "wingmodel.2da",
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "wingmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Angel", "MODEL": "c_angel", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "lock.json"), `{
  "wings:armoredangel": 90,
  "wingmodel:angel": 2
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "modules", "add.json"), `{
  "entries": {
    "wings:armoredangel": {
      "LABEL": "Armored Angel",
      "MODEL": "c_armoredangel",
      "ENVMAP": "default"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "wingmodel", "modules", "ovr_wingprefixes.json"), `{
  "overrides": [
    {"key": "wingmodel:angel", "id": 2, "LABEL": "Wings: Angel"}
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "wingmodel.2da"))
	if err != nil {
		t.Fatalf("read native wingmodel.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "wingmodel.2da"))
	if err != nil {
		t.Fatalf("read reference wingmodel.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("wingmodel output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyTailmodel(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	mkdirAll(t, filepath.Join(root, "reference", "data", "tailmodel", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "base.json"), `{
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "tailmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"},
    {"key": "tailmodel:dragonwhite", "id": 13, "LABEL": "Dragon, White", "MODEL": "c_tail_whi", "ENVMAP": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "lock.json"), `{
  "tails:taildemon": 5050,
  "tails:taildemon2": 5051,
  "tails:taildemon4": 5052,
  "tails:tailfluff": 5053,
  "tailmodel:lizard": 1,
  "tailmodel:dragonwhite": 13
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "modules", "add.json"), `{
  "entries": {
    "tails:taildemon": {"LABEL": "Tail: Demon", "MODEL": "c_taildemon", "ENVMAP": "default"},
    "tails:tailfluff": {"LABEL": "Tail: Demon, Fluffy", "MODEL": "c_tailfluff", "ENVMAP": "default"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "modules", "ovr_tailprefixes.json"), `{
  "overrides": [
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Tail: Lizard"},
    {"key": "tailmodel:dragonwhite", "id": 13, "LABEL": "Tail: Dragon, White"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 4 {
		t.Fatalf("expected tailmodel files to be imported, got %#v", result)
	}

	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "tailmodel", "base.json"),
		filepath.Join(root, "topdata", "data", "tailmodel", "lock.json"),
		filepath.Join(root, "topdata", "data", "tailmodel", "modules", "add.json"),
		filepath.Join(root, "topdata", "data", "tailmodel", "modules", "ovr_tailprefixes.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported tailmodel path %s: %v", path, err)
		}
	}

	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "tailmodel", "base.json"))
	if err != nil {
		t.Fatalf("read tailmodel base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "tailmodel.2da"`) {
		t.Fatalf("expected imported tailmodel output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "tailmodel", "lock.json"))
	if err != nil {
		t.Fatalf("read tailmodel lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"tails:taildemon": 5050`) || !strings.Contains(lockText, `"tailmodel:dragonwhite": 13`) {
		t.Fatalf("expected imported tailmodel lock IDs, got:\n%s", lockText)
	}
}

func TestBuildSupportsCanonicalTailmodel(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "base.json"), `{
  "output": "tailmodel.2da",
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "tailmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "lock.json"), `{
  "tails:taildemon": 5050,
  "tailmodel:lizard": 1
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "add.json"), `{
  "entries": {
    "tails:taildemon": {"LABEL": "Tail: Demon", "MODEL": "c_taildemon", "ENVMAP": "default"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "ovr_tailprefixes.json"), `{
  "overrides": [
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Tail: Lizard"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "tailmodel.2da"))
	if err != nil {
		t.Fatalf("read tailmodel.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "LABEL\tMODEL\tENVMAP\n") ||
		!strings.Contains(text, "0\t(None)\t****\t****\n") ||
		!strings.Contains(text, "1\t\"Tail: Lizard\"\tc_tailliz\tdefault\n") ||
		!strings.Contains(text, "5050\t\"Tail: Demon\"\tc_taildemon\tdefault\n") {
		t.Fatalf("unexpected tailmodel.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalTailmodelMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "tailmodel", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "tailmodel.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tMODEL\tENVMAP\n")
    f.write("0\t(None)\t****\t****\n")
    f.write("1\t\"Tail: Lizard\"\tc_tailliz\tdefault\n")
    for i in range(2, 5050):
        f.write(f"{i}\t****\t****\t****\n")
    f.write("5050\t\"Tail: Demon\"\tc_taildemon\tdefault\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "base.json"), `{
  "output": "tailmodel.2da",
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "tailmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "lock.json"), `{
  "tails:taildemon": 5050,
  "tailmodel:lizard": 1
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "modules", "add.json"), `{
  "entries": {
    "tails:taildemon": {"LABEL": "Tail: Demon", "MODEL": "c_taildemon", "ENVMAP": "default"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "tailmodel", "modules", "ovr_tailprefixes.json"), `{
  "overrides": [
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Tail: Lizard"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "base.json"), `{
  "output": "tailmodel.2da",
  "columns": ["LABEL", "MODEL", "ENVMAP"],
  "rows": [
    {"key": "tailmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "lock.json"), `{
  "tails:taildemon": 5050,
  "tailmodel:lizard": 1
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "add.json"), `{
  "entries": {
    "tails:taildemon": {"LABEL": "Tail: Demon", "MODEL": "c_taildemon", "ENVMAP": "default"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "ovr_tailprefixes.json"), `{
  "overrides": [
    {"key": "tailmodel:lizard", "id": 1, "LABEL": "Tail: Lizard"}
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "tailmodel.2da"))
	if err != nil {
		t.Fatalf("read native tailmodel.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "tailmodel.2da"))
	if err != nil {
		t.Fatalf("read reference tailmodel.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("tailmodel output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyDoortypes(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	mkdirAll(t, filepath.Join(root, "reference", "data", "doortypes", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "base.json"), `{
  "columns": ["Label", "Model", "TileSet", "TemplateResRef", "StringRefGame", "BlockSight", "VisibleModel", "SoundAppType"],
  "rows": [
    {"id": 0, "Label": "Generic", "Model": "****", "TileSet": "****", "TemplateResRef": "****", "StringRefGame": "66717", "BlockSight": "****", "VisibleModel": "****", "SoundAppType": "****"},
    {"id": 1, "Label": "Wall1Door", "Model": "TTR_UDoor_01", "TileSet": "TTR01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": "63491", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "lock.json"), `{
  "txpple:id_6000": 5200,
  "ancarion:id_125": 5226,
  "babayaga:id_1046": 5244
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_pld01.json"), `{
  "entries": {
    "ancarion:id_125": {"Label": "CastleTrans", "Model": "pld_door_01", "TileSet": "PLD01", "TemplateResRef": "nw_door_ttr_15", "StringRefGame": 63520, "BlockSight": 0, "VisibleModel": 0}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_sic11.json"), `{
  "entries": {
    "sens:id_1351": {"Label": "MulsantirGreat01", "Model": "door_mul01", "TileSet": "TNO01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": 5349, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 1}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tapr.json"), `{
  "entries": {
    "txpple:id_6000": {"Label": "Necropolis: Main Door", "Model": "mst01_door_01", "TileSet": "mst01", "TemplateResRef": "nw_door_metal", "StringRefGame": 66717, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 9}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tdm01.json"), `{
  "entries": {
    "projectq:id_5225": {"Label": "DungeonGate", "Model": "tdm02_sdoor_01", "TileSet": "TDM01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": 63491, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 1}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tdx01.json"), `{
  "entries": {
    "sixandzwerkules:id_1167": {"Label": "DungeonMechGate1", "Model": "tdx_door04", "TileSet": "TDX01", "TemplateResRef": "nw_door_ttr_03", "StringRefGame": 63537, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 3}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tei01.json"), `{
  "entries": {
    "zwerkules:id_1451": {"Label": "TEI01_StairTrans1", "Model": "tei_udoor01", "TileSet": "TEI01", "TemplateResRef": "nw_door_ttr_34", "StringRefGame": 5349, "BlockSight": 0, "VisibleModel": 0}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tfm01.json"), `{
  "entries": {
    "babayaga:id_1046": {"Label": "TFM01_BarnDoor", "Model": "tfm_udoor_06", "TileSet": "TFM01", "TemplateResRef": "nw_door_ttr_05", "StringRefGame": 5350, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 1}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 9 {
		t.Fatalf("expected doortypes files to be imported, got %#v", result)
	}

	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "doortypes", "base.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "lock.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_pld01.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_sic11.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tapr.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tdm01.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tdx01.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tei01.json"),
		filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tfm01.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported doortypes path %s: %v", path, err)
		}
	}

	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "doortypes", "base.json"))
	if err != nil {
		t.Fatalf("read doortypes base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "doortypes.2da"`) {
		t.Fatalf("expected imported doortypes output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "doortypes", "lock.json"))
	if err != nil {
		t.Fatalf("read doortypes lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"txpple:id_6000": 5200`) || !strings.Contains(lockText, `"babayaga:id_1046": 5244`) {
		t.Fatalf("expected imported doortypes lock IDs, got:\n%s", lockText)
	}
}

func TestBuildSupportsCanonicalDoortypes(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "doortypes", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "base.json"), `{
  "output": "doortypes.2da",
  "columns": ["Label", "Model", "TileSet", "TemplateResRef", "StringRefGame", "BlockSight", "VisibleModel", "SoundAppType"],
  "rows": [
    {"id": 0, "Label": "Generic", "Model": "****", "TileSet": "****", "TemplateResRef": "****", "StringRefGame": "66717", "BlockSight": "****", "VisibleModel": "****", "SoundAppType": "****"},
    {"id": 1, "Label": "Wall1Door", "Model": "TTR_UDoor_01", "TileSet": "TTR01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": "63491", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "lock.json"), `{
  "txpple:id_6000": 5200
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tapr.json"), `{
  "entries": {
    "txpple:id_6000": {"Label": "Necropolis: Main Door", "Model": "mst01_door_01", "TileSet": "mst01", "TemplateResRef": "nw_door_metal", "StringRefGame": 66717, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 9}
  }
}`+"\n")
	for _, name := range []string{"add_pld01.json", "add_sic11.json", "add_tdm01.json", "add_tdx01.json", "add_tei01.json", "add_tfm01.json"} {
		writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", name), `{"entries": {}}`+"\n")
	}
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "doortypes.2da"))
	if err != nil {
		t.Fatalf("read doortypes.2da: %v", err)
	}
	if !strings.Contains(string(got), "Label\tModel\tTileSet\tTemplateResRef\tStringRefGame\tBlockSight\tVisibleModel\tSoundAppType\n") ||
		!strings.Contains(string(got), "0\tGeneric\t****\t****\t****\t66717\t****\t****\t****\n") ||
		!strings.Contains(string(got), "1\tWall1Door\tTTR_UDoor_01\tTTR01\tnw_door_ttr_01\t63491\t1\t1\t1\n") ||
		!strings.Contains(string(got), "5200\t\"Necropolis: Main Door\"\tmst01_door_01\tmst01\tnw_door_metal\t66717\t1\t1\t9\n") {
		t.Fatalf("unexpected doortypes.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalDoortypesMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "doortypes", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "doortypes", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "doortypes.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tModel\tTileSet\tTemplateResRef\tStringRefGame\tBlockSight\tVisibleModel\tSoundAppType\n")
    f.write("0\tGeneric\t****\t****\t****\t66717\t****\t****\t****\n")
    f.write("1\tWall1Door\tTTR_UDoor_01\tTTR01\tnw_door_ttr_01\t63491\t1\t1\t1\n")
    for row_id in range(2, 5200):
        f.write(f"{row_id}\t****\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("5200\t\"Necropolis: Main Door\"\tmst01_door_01\tmst01\tnw_door_metal\t66717\t1\t1\t9\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "base.json"), `{
  "output": "doortypes.2da",
  "columns": ["Label", "Model", "TileSet", "TemplateResRef", "StringRefGame", "BlockSight", "VisibleModel", "SoundAppType"],
  "rows": [
    {"id": 0, "Label": "Generic", "Model": "****", "TileSet": "****", "TemplateResRef": "****", "StringRefGame": "66717", "BlockSight": "****", "VisibleModel": "****", "SoundAppType": "****"},
    {"id": 1, "Label": "Wall1Door", "Model": "TTR_UDoor_01", "TileSet": "TTR01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": "63491", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "lock.json"), `{
  "txpple:id_6000": 5200
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", "add_tapr.json"), `{
  "entries": {
    "txpple:id_6000": {"Label": "Necropolis: Main Door", "Model": "mst01_door_01", "TileSet": "mst01", "TemplateResRef": "nw_door_metal", "StringRefGame": 66717, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 9}
  }
}`+"\n")
	for _, name := range []string{"add_pld01.json", "add_sic11.json", "add_tdm01.json", "add_tdx01.json", "add_tei01.json", "add_tfm01.json"} {
		writeFile(t, filepath.Join(root, "reference", "data", "doortypes", "modules", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "base.json"), `{
  "output": "doortypes.2da",
  "columns": ["Label", "Model", "TileSet", "TemplateResRef", "StringRefGame", "BlockSight", "VisibleModel", "SoundAppType"],
  "rows": [
    {"id": 0, "Label": "Generic", "Model": "****", "TileSet": "****", "TemplateResRef": "****", "StringRefGame": "66717", "BlockSight": "****", "VisibleModel": "****", "SoundAppType": "****"},
    {"id": 1, "Label": "Wall1Door", "Model": "TTR_UDoor_01", "TileSet": "TTR01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": "63491", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "lock.json"), `{
  "txpple:id_6000": 5200
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tapr.json"), `{
  "entries": {
    "txpple:id_6000": {"Label": "Necropolis: Main Door", "Model": "mst01_door_01", "TileSet": "mst01", "TemplateResRef": "nw_door_metal", "StringRefGame": 66717, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 9}
  }
}`+"\n")
	for _, name := range []string{"add_pld01.json", "add_sic11.json", "add_tdm01.json", "add_tdx01.json", "add_tei01.json", "add_tfm01.json"} {
		writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", name), `{"entries": {}}`+"\n")
	}

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "doortypes.2da"))
	if err != nil {
		t.Fatalf("read native doortypes.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "doortypes.2da"))
	if err != nil {
		t.Fatalf("read reference doortypes.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("doortypes output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyVisualeffects(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	mkdirAll(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "accessories"))
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "base.json"), `{
  "columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Impact_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "ProgFX_Impact", "SoundImpact", "ProgFX_Duration", "SoundDuration", "ProgFX_Cessation", "SoundCessastion", "Ces_HeadCon_Node", "Ces_Impact_Node", "Ces_Root_S_Node", "Ces_Root_M_Node", "Ces_Root_L_Node", "Ces_Root_H_Node", "ShakeType", "ShakeDelay", "ShakeDuration", "LowViolence", "LowQuality", "OrientWithObject"],
  "rows": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "Label": "VFX_FNF_BLINDDEAF", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "****", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"},
    {"key": "visualeffects:com_hitnegative", "id": 288, "Label": "VFX_COM_HIT_NEGATIVE", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "old_sound", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "lock.json"), `{
  "visualeffects:chatbubble": 10100,
  "visualeffects:headaccessory_antlers_l": 10101,
  "visualeffects:vcm_hitmasscrit": 10125,
  "visualeffects:com_hitnegative": 288,
  "visualeffects:fnf_blinddeaf": 18
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "chatbubble.json"), `{
  "entries": {
    "visualeffects:chatbubble": {
      "Label": "VFX_DUR_CHAT_BUBBLE",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "vdr_chatbubble",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "add_onhitvfx.json"), `{
  "entries": {
    "visualeffects:vcm_hitmasscrit": {
      "Label": "VFX_COM_HIT_MASSIVE_CRITICALS",
      "Type_FD": "F",
      "OrientWithGround": 0,
      "Imp_Impact_Node": "vcm_hitmasscrit",
      "OrientWithObject": 0
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "fix_comhitnegative.json"), `{
  "overrides": [
    {"key": "visualeffects:com_hitnegative", "id": 288, "SoundImpact": "scm_hitsmite"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "ovr_lowqualitymodels.json"), `{
  "overrides": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "LowQuality": "vff_explblind_l"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "accessories", "add_eos.json"), `{
  "entries": {
    "visualeffects:headaccessory_antlers_l": {
      "Label": "headaccessory_ANTLERS_L",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "hfx_antlers_l",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 6 {
		t.Fatalf("expected visualeffects files to be imported, got %#v", result)
	}

	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "visualeffects", "base.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "lock.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "modules", "add_onhitvfx.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "modules", "chatbubble.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "modules", "fix_comhitnegative.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "modules", "ovr_lowqualitymodels.json"),
		filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories", "add_eos.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported visualeffects path %s: %v", path, err)
		}
	}

	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "visualeffects", "base.json"))
	if err != nil {
		t.Fatalf("read visualeffects base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "visualeffects.2da"`) {
		t.Fatalf("expected imported visualeffects output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "visualeffects", "lock.json"))
	if err != nil {
		t.Fatalf("read visualeffects lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"visualeffects:chatbubble": 10100`) || !strings.Contains(lockText, `"visualeffects:headaccessory_antlers_l": 10101`) {
		t.Fatalf("expected imported visualeffects lock IDs, got:\n%s", lockText)
	}
}

func TestBuildSupportsCanonicalVisualeffects(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories"))
	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_Impact_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "ProgFX_Impact", "SoundImpact", "ProgFX_Duration", "SoundDuration", "ProgFX_Cessation", "SoundCessastion", "Ces_HeadCon_Node", "Ces_Impact_Node", "Ces_Root_S_Node", "Ces_Root_M_Node", "Ces_Root_L_Node", "Ces_Root_H_Node", "ShakeType", "ShakeDelay", "ShakeDuration", "LowViolence", "LowQuality", "OrientWithObject"],
  "rows": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "Label": "VFX_FNF_BLINDDEAF", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "****", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"},
    {"key": "visualeffects:com_hitnegative", "id": 288, "Label": "VFX_COM_HIT_NEGATIVE", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "old_sound", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "lock.json"), `{
  "visualeffects:chatbubble": 10100,
  "visualeffects:headaccessory_antlers_l": 10101,
  "visualeffects:vcm_hitmasscrit": 10125,
  "visualeffects:com_hitnegative": 288,
  "visualeffects:fnf_blinddeaf": 18
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "chatbubble.json"), `{
  "entries": {
    "visualeffects:chatbubble": {
      "Label": "VFX_DUR_CHAT_BUBBLE",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "vdr_chatbubble",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "add_onhitvfx.json"), `{
  "entries": {
    "visualeffects:vcm_hitmasscrit": {
      "Label": "VFX_COM_HIT_MASSIVE_CRITICALS",
      "Type_FD": "F",
      "OrientWithGround": 0,
      "Imp_Impact_Node": "vcm_hitmasscrit",
      "OrientWithObject": 0
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "fix_comhitnegative.json"), `{
  "overrides": [
    {"key": "visualeffects:com_hitnegative", "id": 288, "SoundImpact": "scm_hitsmite"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "ovr_lowqualitymodels.json"), `{
  "overrides": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "LowQuality": "vff_explblind_l"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories", "add_eos.json"), `{
  "entries": {
    "visualeffects:headaccessory_antlers_l": {
      "Label": "headaccessory_ANTLERS_L",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "hfx_antlers_l",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da"))
	if err != nil {
		t.Fatalf("read visualeffects.2da: %v", err)
	}
	text := string(got)
	for _, want := range []string{
		"18\tVFX_FNF_BLINDDEAF\tF\t0",
		"288\tVFX_COM_HIT_NEGATIVE\tF\t0",
		"10100\tVFX_DUR_CHAT_BUBBLE\tD\t0",
		"10101\theadaccessory_ANTLERS_L\tD\t0",
		"10125\tVFX_COM_HIT_MASSIVE_CRITICALS\tF\t0",
		"scm_hitsmite",
		"vff_explblind_l",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected visualeffects.2da to contain %q, got:\n%s", want, text)
		}
	}
}

func TestBuildCanonicalVisualeffectsMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "visualeffects", "modules", "accessories"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "visualeffects.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tType_FD\tOrientWithGround\tImp_HeadCon_Node\tImp_Impact_Node\tImp_Root_S_Node\tImp_Root_M_Node\tImp_Root_L_Node\tImp_Root_H_Node\tProgFX_Impact\tSoundImpact\tProgFX_Duration\tSoundDuration\tProgFX_Cessation\tSoundCessastion\tCes_HeadCon_Node\tCes_Impact_Node\tCes_Root_S_Node\tCes_Root_M_Node\tCes_Root_L_Node\tCes_Root_H_Node\tShakeType\tShakeDelay\tShakeDuration\tLowViolence\tLowQuality\tOrientWithObject\n")
    f.write("18\tVFX_FNF_BLINDDEAF\tF\t0\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\tvff_explblind_l\t0\n")
    f.write("288\tVFX_COM_HIT_NEGATIVE\tF\t0\t****\t****\t****\t****\t****\t****\tscm_hitsmite\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t0\n")
    f.write("10100\tVFX_DUR_CHAT_BUBBLE\tD\t0\tvdr_chatbubble\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t0\n")
    f.write("10101\theadaccessory_ANTLERS_L\tD\t0\thfx_antlers_l\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t0\n")
    f.write("10125\tVFX_COM_HIT_MASSIVE_CRITICALS\tF\t0\t****\tvcm_hitmasscrit\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t0\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`+"\n")
	for _, target := range []string{"topdata", "reference"} {
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "base.json"), `{
  "output": "visualeffects.2da",
  "columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Impact_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "ProgFX_Impact", "SoundImpact", "ProgFX_Duration", "SoundDuration", "ProgFX_Cessation", "SoundCessastion", "Ces_HeadCon_Node", "Ces_Impact_Node", "Ces_Root_S_Node", "Ces_Root_M_Node", "Ces_Root_L_Node", "Ces_Root_H_Node", "ShakeType", "ShakeDelay", "ShakeDuration", "LowViolence", "LowQuality", "OrientWithObject"],
  "rows": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "Label": "VFX_FNF_BLINDDEAF", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "****", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"},
    {"key": "visualeffects:com_hitnegative", "id": 288, "Label": "VFX_COM_HIT_NEGATIVE", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "old_sound", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"}
  ]
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "lock.json"), `{
  "visualeffects:chatbubble": 10100,
  "visualeffects:headaccessory_antlers_l": 10101,
  "visualeffects:vcm_hitmasscrit": 10125,
  "visualeffects:com_hitnegative": 288,
  "visualeffects:fnf_blinddeaf": 18
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "modules", "chatbubble.json"), `{
  "entries": {
    "visualeffects:chatbubble": {
      "Label": "VFX_DUR_CHAT_BUBBLE",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "vdr_chatbubble",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "modules", "add_onhitvfx.json"), `{
  "entries": {
    "visualeffects:vcm_hitmasscrit": {
      "Label": "VFX_COM_HIT_MASSIVE_CRITICALS",
      "Type_FD": "F",
      "OrientWithGround": 0,
      "Imp_Impact_Node": "vcm_hitmasscrit",
      "OrientWithObject": 0
    }
  }
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "modules", "fix_comhitnegative.json"), `{
  "overrides": [
    {"key": "visualeffects:com_hitnegative", "id": 288, "SoundImpact": "scm_hitsmite"}
  ]
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "modules", "ovr_lowqualitymodels.json"), `{
  "overrides": [
    {"key": "visualeffects:fnf_blinddeaf", "id": 18, "LowQuality": "vff_explblind_l"}
  ]
}`+"\n")
		writeFile(t, filepath.Join(root, target, "data", "visualeffects", "modules", "accessories", "add_eos.json"), `{
  "entries": {
    "visualeffects:headaccessory_antlers_l": {
      "Label": "headaccessory_ANTLERS_L",
      "Type_FD": "D",
      "OrientWithGround": "0",
      "Imp_HeadCon_Node": "hfx_antlers_l",
      "Imp_Impact_Node": "****",
      "Imp_Root_S_Node": "****",
      "Imp_Root_M_Node": "****",
      "Imp_Root_L_Node": "****",
      "Imp_Root_H_Node": "****",
      "ProgFX_Impact": "****",
      "SoundImpact": "****",
      "ProgFX_Duration": "****",
      "SoundDuration": "****",
      "ProgFX_Cessation": "****",
      "SoundCessastion": "****",
      "Ces_HeadCon_Node": "****",
      "Ces_Impact_Node": "****",
      "Ces_Root_S_Node": "****",
      "Ces_Root_M_Node": "****",
      "Ces_Root_L_Node": "****",
      "Ces_Root_H_Node": "****",
      "ShakeType": "****",
      "ShakeDelay": "****",
      "ShakeDuration": "****",
      "LowViolence": "****",
      "LowQuality": "****",
      "OrientWithObject": "0"
    }
  }
}`+"\n")
	}

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "visualeffects.2da"))
	if err != nil {
		t.Fatalf("read native visualeffects.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "visualeffects.2da"))
	if err != nil {
		t.Fatalf("read reference visualeffects.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("visualeffects output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

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"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	mkdirAll(t, filepath.Join(root, "reference", "data", "loadscreens", "modules"))
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "base.json"), `{
  "columns": ["Label", "ScriptingName", "BMPResRef", "TileSet", "StrRef"],
  "rows": [
    {"id": 0, "Label": "Random", "ScriptingName": "****", "BMPResRef": "****", "TileSet": "****", "StrRef": "63402"},
    {"id": 333, "Label": "Daggerford12", "ScriptingName": "AREA_TRANSITION_DAG_12", "BMPResRef": "LS_DAG_12", "TileSet": "TMS01", "StrRef": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "lock.json"), `{
  "loadscreens:vdungeon01": 334,
  "loadscreens:tcd_01": 335,
  "loadscreens:ori_01": 336,
  "loadscreens:ori_02": 337,
  "loadscreens:ori_03": 338,
  "loadscreens:ori_04": 339,
  "loadscreens:ori_05": 340
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_lsvd.json"), `{
  "entries": {
    "loadscreens:vdungeon01": {
      "Label": "VDungeon01",
      "ScriptingName": "AREA_TRANSITION_VDUNGEON_01",
      "BMPResRef": "LS_VD_01",
      "TileSet": "TVD"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_ori.json"), `{
  "entries": {
    "loadscreens:ori_01": {
      "Label": "Bamboo",
      "ScriptingName": "AREA_TRANSITION_RURAL_06",
      "BMPResRef": "LS_Ori_01",
      "TileSet": "TTR01"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_tcd01.json"), `{
  "entries": {
    "loadscreens:tcd_01": {
      "Label": "cd01",
      "ScriptingName": "AREA_TRANSITION_CDUNGEON_01",
      "BMPResRef": "ls_tcd_01",
      "TileSet": "TCD01",
      "StrRef": 68555
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 5 {
		t.Fatalf("expected loadscreens files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "loadscreens", "base.json"),
		filepath.Join(root, "topdata", "data", "loadscreens", "lock.json"),
		filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_lsvd.json"),
		filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_ori.json"),
		filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_tcd01.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported loadscreens path %s: %v", path, err)
		}
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "loadscreens", "lock.json"))
	if err != nil {
		t.Fatalf("read loadscreens lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"loadscreens:ori_05": 340`) {
		t.Fatalf("expected imported loadscreens lock IDs, got:\n%s", string(lockRaw))
	}
}

func TestBuildSupportsCanonicalLoadscreens(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "base.json"), `{
  "columns": ["Label", "ScriptingName", "BMPResRef", "TileSet", "StrRef"],
  "rows": [
    {"id": 0, "Label": "Random", "ScriptingName": "****", "BMPResRef": "****", "TileSet": "****", "StrRef": "63402"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "lock.json"), `{
  "loadscreens:vdungeon01": 334
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_lsvd.json"), `{
  "entries": {
    "loadscreens:vdungeon01": {
      "Label": "VDungeon01",
      "ScriptingName": "AREA_TRANSITION_VDUNGEON_01",
      "BMPResRef": "LS_VD_01",
      "TileSet": "TVD"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_ori.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_tcd01.json"), `{"entries": {}}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "loadscreens.2da"))
	if err != nil {
		t.Fatalf("read loadscreens.2da: %v", err)
	}
	if !strings.Contains(string(got), "Label\tScriptingName\tBMPResRef\tTileSet\tStrRef\n") ||
		!strings.Contains(string(got), "0\tRandom\t****\t****\t****\t63402\n") ||
		!strings.Contains(string(got), "334\tVDungeon01\tAREA_TRANSITION_VDUNGEON_01\tLS_VD_01\tTVD\t****\n") {
		t.Fatalf("unexpected loadscreens.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalLoadscreensMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "loadscreens", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "loadscreens.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tScriptingName\tBMPResRef\tTileSet\tStrRef\n")
    f.write("0\tRandom\t****\t****\t****\t63402\n")
    for row_id in range(1, 334):
        f.write(f"{row_id}\t****\t****\t****\t****\t****\n")
    f.write("334\tVDungeon01\tAREA_TRANSITION_VDUNGEON_01\tLS_VD_01\tTVD\t****\n")
    f.write("335\tcd01\tAREA_TRANSITION_CDUNGEON_01\tls_tcd_01\tTCD01\t68555\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "base.json"), `{
  "columns": ["Label", "ScriptingName", "BMPResRef", "TileSet", "StrRef"],
  "rows": [
    {"id": 0, "Label": "Random", "ScriptingName": "****", "BMPResRef": "****", "TileSet": "****", "StrRef": "63402"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "lock.json"), `{
  "loadscreens:vdungeon01": 334,
  "loadscreens:tcd_01": 335
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_lsvd.json"), `{
  "entries": {
    "loadscreens:vdungeon01": {
      "Label": "VDungeon01",
      "ScriptingName": "AREA_TRANSITION_VDUNGEON_01",
      "BMPResRef": "LS_VD_01",
      "TileSet": "TVD"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_ori.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "loadscreens", "modules", "add_tcd01.json"), `{
  "entries": {
    "loadscreens:tcd_01": {
      "Label": "cd01",
      "ScriptingName": "AREA_TRANSITION_CDUNGEON_01",
      "BMPResRef": "ls_tcd_01",
      "TileSet": "TCD01",
      "StrRef": 68555
    }
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "loadscreens.2da"))
	if err != nil {
		t.Fatalf("read native loadscreens.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "loadscreens.2da"))
	if err != nil {
		t.Fatalf("read reference loadscreens.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("loadscreens output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeImportsLegacyGenericdoors(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "genericdoors", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "base.json"), `{
  "columns": ["Label", "StrRef", "ModelName", "BlockSight", "VisibleModel", "SoundAppType", "Name"],
  "rows": [
    {"id": 0, "Label": "Wood_strong", "StrRef": "5349", "ModelName": "T_DOOR01", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "0", "Name": "63750"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "lock.json"), `{
  "babayaga:hs_door60": 3200,
  "orientaltileset:t_Door12": 3202
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "modules", "add_mb017.json"), `{
  "entries": {
    "babayaga:hs_door60": {
      "Label": "Door60",
      "StrRef": "****",
      "ModelName": "hs_door60",
      "BlockSight": "1",
      "VisibleModel": "1",
      "SoundAppType": "0",
      "Name": "****"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "modules", "add_ori.json"), `{
  "entries": {
    "orientaltileset:t_Door12": {
      "Label": "Door12",
      "StrRef": "5349",
      "ModelName": "t_Door12",
      "BlockSight": "1",
      "VisibleModel": "1",
      "SoundAppType": "0",
      "Name": "14445"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 4 {
		t.Fatalf("expected genericdoors files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "genericdoors", "base.json"),
		filepath.Join(root, "topdata", "data", "genericdoors", "lock.json"),
		filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_mb017.json"),
		filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_ori.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported genericdoors path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "genericdoors", "base.json"))
	if err != nil {
		t.Fatalf("read genericdoors base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "genericdoors.2da"`) {
		t.Fatalf("expected imported genericdoors output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "genericdoors", "lock.json"))
	if err != nil {
		t.Fatalf("read genericdoors lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"orientaltileset:t_Door12": 3202`) {
		t.Fatalf("expected imported genericdoors lock IDs, got:\n%s", string(lockRaw))
	}
}

func TestBuildSupportsCanonicalGenericdoors(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "base.json"), `{
  "output": "genericdoors.2da",
  "columns": ["Label", "StrRef", "ModelName", "BlockSight", "VisibleModel", "SoundAppType", "Name"],
  "rows": [
    {"id": 0, "Label": "Wood_strong", "StrRef": "5349", "ModelName": "T_DOOR01", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "0", "Name": "63750"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "lock.json"), `{
  "babayaga:hs_door60": 3200
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_mb017.json"), `{
  "entries": {
    "babayaga:hs_door60": {
      "Label": "Door60",
      "StrRef": "****",
      "ModelName": "hs_door60",
      "BlockSight": "1",
      "VisibleModel": "1",
      "SoundAppType": "0",
      "Name": "****"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_ori.json"), `{"entries": {}}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "genericdoors.2da"))
	if err != nil {
		t.Fatalf("read genericdoors.2da: %v", err)
	}
	if !strings.Contains(string(got), "Label\tStrRef\tModelName\tBlockSight\tVisibleModel\tSoundAppType\tName\n") ||
		!strings.Contains(string(got), "0\tWood_strong\t5349\tT_DOOR01\t1\t1\t0\t63750\n") ||
		!strings.Contains(string(got), "3200\tDoor60\t****\ths_door60\t1\t1\t0\t****\n") {
		t.Fatalf("unexpected genericdoors.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalGenericdoorsMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "genericdoors", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "genericdoors.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tStrRef\tModelName\tBlockSight\tVisibleModel\tSoundAppType\tName\n")
    f.write("0\tWood_strong\t5349\tT_DOOR01\t1\t1\t0\t63750\n")
    for row_id in range(1, 3200):
        f.write(f"{row_id}\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("3200\tDoor60\t****\ths_door60\t1\t1\t0\t****\n")
    f.write("3201\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("3202\tDoor12\t5349\tt_Door12\t1\t1\t0\t14445\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "base.json"), `{
  "columns": ["Label", "StrRef", "ModelName", "BlockSight", "VisibleModel", "SoundAppType", "Name"],
  "rows": [
    {"id": 0, "Label": "Wood_strong", "StrRef": "5349", "ModelName": "T_DOOR01", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "0", "Name": "63750"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "lock.json"), `{
  "babayaga:hs_door60": 3200,
  "orientaltileset:t_Door12": 3202
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "modules", "add_mb017.json"), `{
  "entries": {
    "babayaga:hs_door60": {
      "Label": "Door60",
      "StrRef": "****",
      "ModelName": "hs_door60",
      "BlockSight": "1",
      "VisibleModel": "1",
      "SoundAppType": "0",
      "Name": "****"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "genericdoors", "modules", "add_ori.json"), `{
  "entries": {
    "orientaltileset:t_Door12": {
      "Label": "Door12",
      "StrRef": "5349",
      "ModelName": "t_Door12",
      "BlockSight": "1",
      "VisibleModel": "1",
      "SoundAppType": "0",
      "Name": "14445"
    }
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "genericdoors.2da"))
	if err != nil {
		t.Fatalf("read native genericdoors.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "genericdoors.2da"))
	if err != nil {
		t.Fatalf("read reference genericdoors.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("genericdoors output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyBaseitems(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "baseitems", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "tlk"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "base.json"), `{
  "columns": ["Name", "label", "MaxRange"],
  "rows": [
    {"key": "baseitems:shortsword", "id": 0, "Name": "106", "label": "shortsword", "MaxRange": "100"},
    {"key": "baseitems:helmet", "id": 17, "Name": "500", "label": "helmet", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "lock.json"), `{
  "baseitems:shortsword": 0,
  "baseitems:helmet": 17,
  "baseitems:coin": 113
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "blunderbuss.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "coin.json"), `{
  "entries": {
    "baseitems:coin": {
      "Name": { "tlk": "baseitems:coin.name" },
      "label": "Coin",
      "MaxRange": "100"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "estoc.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "heavymace.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_baseitems.json"), `{
  "overrides": [
    {"key": "baseitems:shortsword", "id": 0, "label": "shortsword"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_cloak255.json"), `{"overrides": []}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_helmet255.json"), `{
  "overrides": [
    {"key": "baseitems:helmet", "id": 17, "MaxRange": "255"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "maulandfalchion.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "shortspear.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "base.json"), `{
  "language": "en",
  "entries": {
    "baseitems:coin.name": { "text": "Coin" }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "baseitems:coin.name": 70000
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 10 {
		t.Fatalf("expected baseitems files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "baseitems", "base.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "lock.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "blunderbuss.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "coin.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "estoc.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "heavymace.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "maulandfalchion.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_baseitems.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_cloak255.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_helmet255.json"),
		filepath.Join(root, "topdata", "data", "baseitems", "modules", "shortspear.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported baseitems path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "base.json"))
	if err != nil {
		t.Fatalf("read baseitems base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "baseitems.2da"`) {
		t.Fatalf("expected imported baseitems output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "lock.json"))
	if err != nil {
		t.Fatalf("read baseitems lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"baseitems:coin": 113`) {
		t.Fatalf("expected imported baseitems lock IDs, got:\n%s", string(lockRaw))
	}
	moduleRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "modules", "coin.json"))
	if err != nil {
		t.Fatalf("read baseitems coin module: %v", err)
	}
	if !strings.Contains(string(moduleRaw), `"text": "Coin"`) {
		t.Fatalf("expected imported baseitems module tlk to be inlined, got:\n%s", string(moduleRaw))
	}
}

func TestBuildSupportsCanonicalBaseitems(t *testing.T) {
	root := testProjectRoot(t)
	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", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["Name", "label", "MaxRange"],
  "rows": [
    {"key": "baseitems:shortsword", "id": 0, "Name": "106", "label": "shortsword", "MaxRange": "100"},
    {"key": "baseitems:helmet", "id": 17, "Name": "500", "label": "helmet", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{
  "baseitems:shortsword": 0,
  "baseitems:helmet": 17,
  "baseitems:coin": 113
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "blunderbuss.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "coin.json"), `{
  "entries": {
    "baseitems:coin": {
      "Name": "Coin",
      "label": "Coin",
      "MaxRange": "100",
      "meta": {
        "wiki": {
          "reqfeats": [{"id": "feat:weaponproficiencysimple"}]
        }
      }
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "estoc.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "heavymace.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_baseitems.json"), `{
  "overrides": [
    {"key": "baseitems:shortsword", "id": 0, "label": "shortsword"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_cloak255.json"), `{"overrides": []}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_helmet255.json"), `{
  "overrides": [
    {"key": "baseitems:helmet", "id": 17, "MaxRange": "255"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "shortspear.json"), `{"entries": {}}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read baseitems.2da: %v", err)
	}
	if !strings.Contains(string(got), "Name\tlabel\tMaxRange\n") ||
		!strings.Contains(string(got), "0\t106\tshortsword\t100\n") ||
		!strings.Contains(string(got), "17\t500\thelmet\t255\n") ||
		!strings.Contains(string(got), "113\tCoin\tCoin\t100\n") {
		t.Fatalf("unexpected baseitems.2da output:\n%s", string(got))
	}
	if strings.Contains(string(got), "WIKIREQFEATS") {
		t.Fatalf("authoring-only WIKI fields leaked into baseitems.2da:\n%s", string(got))
	}
}

func TestBuildUsesBaseDialogLegacyEntries(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{
  "language": "en",
  "entries": {
    "baseitems:coin.name": {"text": "Coin"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["Name", "label"],
  "rows": [
    {"key": "baseitems:coin", "id": 113, "Name": {"tlk": "baseitems:coin.name"}, "label": "Coin"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read baseitems.2da: %v", err)
	}
	if !strings.Contains(string(got), "113\t16777216\tCoin\n") {
		t.Fatalf("expected base_dialog-backed strref in output, got:\n%s", string(got))
	}
	stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", ".tlk_state.json"))
	if err != nil {
		t.Fatalf("read tlk state: %v", err)
	}
	if !strings.Contains(string(stateRaw), `"baseitems:coin.name"`) {
		t.Fatalf("expected base_dialog key to be tracked in tlk state, got:\n%s", string(stateRaw))
	}
}

func TestNormalize2DAForCompareDropsDeprecatedWikiColumns(t *testing.T) {
	input := []byte("2DA V2.0\n\nName\tWIKIGENERATE\tValue\n0\tCoin\t****\t100\n")
	got := normalize2DAForCompare("test.2da", input)
	expected := "2DA V2.0\n\nName\tValue\n0\tCoin\t100\n"
	if string(got) != expected {
		t.Fatalf("unexpected normalized 2da:\n%s", string(got))
	}
}

func TestBuildOverridePrecedenceSuppressesBaseAndEntries(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"key": "dense:base", "id": 0, "Label": "Base", "Value": "base"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:entry": 1
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
  "entries": {
    "dense:entry": {
      "Label": "Entry",
      "Value": "entry"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "overrides.json"), `{
  "overrides": [
    {"key": "dense:base", "Label": "Override", "Value": null},
    {"key": "dense:entry", "Label": "EntryOverride", "Value": null}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "0\tOverride\t****\n") {
		t.Fatalf("expected base row override to win and null out Value, got:\n%s", text)
	}
	if !strings.Contains(text, "1\tEntryOverride\t****\n") {
		t.Fatalf("expected entry row override to win and null out Value, got:\n%s", text)
	}
}

func TestBuildNullRowRetiresLockIdentityGlobally(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "key": "dense:one", "Label": "One", "Value": "one"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:one": 0
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rmv_one.json"), `{
  "overrides": [
    {"id": 0, "key": null, "null": true}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read dense lock: %v", err)
	}
	if strings.Contains(string(lockRaw), `"dense:one"`) {
		t.Fatalf("expected nulled row to retire lock identity, got:\n%s", string(lockRaw))
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	if !strings.Contains(string(got), "0\t****\t****\n") {
		t.Fatalf("expected nulled dense row in output, got:\n%s", string(got))
	}
}

func TestResolveOverrideTargetPrefersIDOverKey(t *testing.T) {
	keyRow := map[string]any{"id": 192, "key": "feat:skillfocus_intimidate"}
	idRow := map[string]any{"id": 916, "key": "feat:taunt"}
	rowByID := map[int]map[string]any{
		192: keyRow,
		916: idRow,
	}
	rowByKey := map[string]map[string]any{
		"feat:skillfocus_intimidate": keyRow,
		"feat:taunt":                 idRow,
	}
	override := map[string]any{
		"key": "feat:skillfocus_intimidate",
		"id":  916,
	}
	got, err := resolveOverrideTarget("feat", override, rowByID, rowByKey)
	if err != nil {
		t.Fatalf("resolveOverrideTarget failed: %v", err)
	}
	if got["id"] != 916 {
		t.Fatalf("expected id-targeted row id 916, got %v", got["id"])
	}
}

func TestUpdateOverrideRowKeyRetiresOldKeyEvenIfReferenced(t *testing.T) {
	row := map[string]any{"id": 443, "key": "spells:etherealness"}
	rowByKey := map[string]map[string]any{
		"spells:etherealness": row,
	}
	lockData := map[string]int{
		"spells:etherealness": 443,
	}
	expanded := map[string]any{
		"key": "spells:greater_sanctuary",
	}

	changed, retiredKey, err := updateOverrideRowKey("spells", row, expanded, rowByKey, lockData)
	if err != nil {
		t.Fatalf("updateOverrideRowKey failed: %v", err)
	}
	if !changed {
		t.Fatal("expected key reassignment to mark the row changed")
	}
	if retiredKey != "spells:etherealness" {
		t.Fatalf("expected retired key spells:etherealness, got %q", retiredKey)
	}
	if row["key"] != "spells:greater_sanctuary" {
		t.Fatalf("expected row key to be updated, got %v", row["key"])
	}
	if _, ok := lockData["spells:etherealness"]; ok {
		t.Fatalf("expected old key to be removed from lock data, got %#v", lockData)
	}
	if lockData["spells:greater_sanctuary"] != 443 {
		t.Fatalf("expected new key to be locked to row 443, got %#v", lockData)
	}

	referencedKeys := map[string]struct{}{
		"spells:etherealness": {},
	}
	retiredKeys := map[string]struct{}{
		retiredKey: {},
	}
	rows := []map[string]any{row}
	pruned, updated := pruneLockDataToActiveRows(lockData, rows, referencedKeys, retiredKeys)
	if updated != 0 {
		t.Fatalf("expected no lock id updates after prune, got %d", updated)
	}
	if pruned != 0 {
		t.Fatalf("expected no additional prune once old key was already retired, got %d", pruned)
	}
	if _, ok := lockData["spells:etherealness"]; ok {
		t.Fatalf("expected retired key to stay removed even when referenced, got %#v", lockData)
	}
}

func TestUpdateOverrideRowKeyDoesNotDeleteRelocatedReplacementLock(t *testing.T) {
	row := map[string]any{"id": 58, "key": "baseitems:shortspear"}
	rowByKey := map[string]map[string]any{
		"baseitems:shortspear": row,
	}
	lockData := map[string]int{
		"baseitems:shortspear": 126,
	}
	expanded := map[string]any{
		"key": "baseitems:spear",
	}

	changed, retiredKey, err := updateOverrideRowKey("baseitems", row, expanded, rowByKey, lockData)
	if err != nil {
		t.Fatalf("updateOverrideRowKey failed: %v", err)
	}
	if !changed {
		t.Fatal("expected key reassignment to mark the row changed")
	}
	if retiredKey != "baseitems:shortspear" {
		t.Fatalf("expected retired key baseitems:shortspear, got %q", retiredKey)
	}
	if lockData["baseitems:shortspear"] != 126 {
		t.Fatalf("expected relocated replacement lock to survive old row retirement, got %#v", lockData)
	}
	if lockData["baseitems:spear"] != 58 {
		t.Fatalf("expected new row key to lock to row 58, got %#v", lockData)
	}
}

func TestRetireRowIdentityDoesNotDeleteRelocatedReplacementLock(t *testing.T) {
	row := map[string]any{"id": 916, "key": "feat:skill_focus_intimidate"}
	lockData := map[string]int{
		"feat:skill_focus_intimidate": 1333,
	}
	retiredKeys := map[string]struct{}{}

	retireRowIdentity(row, lockData, retiredKeys)

	if lockData["feat:skill_focus_intimidate"] != 1333 {
		t.Fatalf("expected replacement lock to survive nulled old row, got %#v", lockData)
	}
	if _, ok := retiredKeys["feat:skill_focus_intimidate"]; !ok {
		t.Fatalf("expected old row key to be recorded as retired")
	}
}

func TestBuildPrunesLockedIDThatTargetsManualBaseSpace(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "Label": "****", "Value": "****"},
    {"id": 1, "key": "dense:base", "Label": "Base", "Value": "base"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:new": 0,
  "dense:base": 1
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
  "entries": {
    "dense:new": {
      "Label": "New",
      "Value": "new"
    }
  }
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "0\t****\t****\n") {
		t.Fatalf("expected manual null row 0 to be preserved, got:\n%s", text)
	}
	if !strings.Contains(text, "2\tNew\tnew\n") {
		t.Fatalf("expected generated entry to be regenerated after base boundary, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"dense:new": 2`) {
		t.Fatalf("expected invalid low lock id to be regenerated at row 2, got:\n%s", string(lockRaw))
	}
}

func TestBuildPrunesBaseKeyAfterOverrideRenamesRow(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "key": "dense:old", "Label": "Old", "Value": "old"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:old": 0
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
  "overrides": [
    {
      "id": 0,
      "key": "dense:new",
      "Label": "New",
      "Value": "new"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	if !strings.Contains(string(got), "0\tNew\tnew\n") {
		t.Fatalf("expected renamed row override to win, got:\n%s", string(got))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	lockText := string(lockRaw)
	if strings.Contains(lockText, `"dense:old"`) {
		t.Fatalf("expected old base key to be pruned after row rename, got:\n%s", lockText)
	}
	if !strings.Contains(lockText, `"dense:new": 0`) {
		t.Fatalf("expected new override key to be locked to row 0, got:\n%s", lockText)
	}
}

func TestBuildDuplicateBaseKeyUsesSurvivingRowAfterOverrideRename(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 3, "key": "dense:duplicate", "Label": "Renamed", "Value": "renamed"},
    {"id": 7, "key": "dense:duplicate", "Label": "Survivor", "Value": "survivor"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:duplicate": 3
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
  "overrides": [
    {
      "id": 3,
      "key": "dense:renamed",
      "Label": "Renamed",
      "Value": "renamed"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "3\tRenamed\trenamed\n") || !strings.Contains(text, "7\tSurvivor\tsurvivor\n") {
		t.Fatalf("expected renamed and surviving duplicate-key rows to keep distinct ids, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"dense:renamed": 3`) {
		t.Fatalf("expected renamed key to keep original row id, got:\n%s", lockText)
	}
	if !strings.Contains(lockText, `"dense:duplicate": 7`) {
		t.Fatalf("expected surviving duplicate key to lock to surviving row id, got:\n%s", lockText)
	}
}

func TestBuildDuplicateBaseKeyUsesEarlierSurvivingRowAfterOverrideRename(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 3, "key": "dense:duplicate", "Label": "Survivor", "Value": "survivor"},
    {"id": 7, "key": "dense:duplicate", "Label": "Renamed", "Value": "renamed"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:duplicate": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
  "overrides": [
    {
      "id": 7,
      "key": "dense:renamed",
      "Label": "Renamed",
      "Value": "renamed"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "3\tSurvivor\tsurvivor\n") || !strings.Contains(text, "7\tRenamed\trenamed\n") {
		t.Fatalf("expected surviving and renamed duplicate-key rows to keep distinct ids, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"dense:renamed": 7`) {
		t.Fatalf("expected renamed key to keep original row id, got:\n%s", lockText)
	}
	if !strings.Contains(lockText, `"dense:duplicate": 3`) {
		t.Fatalf("expected surviving duplicate key to lock to surviving row id, got:\n%s", lockText)
	}
}

func TestBuildAutoAllocatedRowsReserveExplicitModuleIDs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "key": "dense:base", "Label": "Base", "Value": "base"},
    {"id": 1, "Label": "****", "Value": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:base": 0
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "a_entries.json"), `{
  "entries": {
    "dense:auto": {
      "Label": "Auto",
      "Value": "auto"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "z_explicit.json"), `{
  "overrides": [
    {
      "key": "dense:explicit",
      "id": 2,
      "Label": "Explicit",
      "Value": "explicit"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "2\tExplicit\texplicit\n") {
		t.Fatalf("expected explicit module id 2 to be preserved, got:\n%s", text)
	}
	if !strings.Contains(text, "3\tAuto\tauto\n") {
		t.Fatalf("expected auto entry to skip explicit module id 2, got:\n%s", text)
	}
}

func TestBuildGeneratedApplyAfterPrunesLowLockedID(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["Label", "STRREF", "DESCRIPTION"],
  "rows": [
    {"id": 10, "key": "masterfeats:skillfocus", "Label": "SkillFocus", "STRREF": "100", "DESCRIPTION": "1000"},
    {"id": 11, "key": "masterfeats:greaterskillfocus", "Label": "GreaterSkillFocus", "STRREF": "101", "DESCRIPTION": "1001"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
  "masterfeats:skillfocus": 10,
  "masterfeats:greaterskillfocus": 11
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "HideFromLevelUp"],
  "rows": [
    {"id": 0, "key": "skills:profession_herbalist", "Label": "Profession Herbalist", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
  "skills:profession_herbalist": 0
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["Label", "FEAT", "MASTERFEAT", "REQSKILL", "Constant"],
  "rows": [
    {"id": 0, "Label": "****", "FEAT": "****", "MASTERFEAT": "****", "REQSKILL": "****", "Constant": "****"},
    {"id": 10, "key": "feat:skillfocus", "Label": "SkillFocus", "FEAT": "100", "MASTERFEAT": "10", "REQSKILL": "****", "Constant": "FEAT_SKILL_FOCUS"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:skillfocus": 10,
  "feat:skillfocus_professionherbalist": 0,
  "feat:greaterskillfocus_professionherbalist": 0
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removed.json"), `{
  "overrides": [
    {
      "key": null,
      "id": 0,
      "Label": "****",
      "FEAT": "****",
      "MASTERFEAT": "****",
      "REQSKILL": "****",
      "Constant": "****"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "greater_skill_focus.json"), `{
  "family": "greater_skill_focus",
  "family_key": "greaterskillfocus",
  "template": "masterfeats:greaterskillfocus",
  "apply_after_modules": true,
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  },
  "label_prefix": "GREATER_SKILL_FOCUS",
  "name_prefix": "Greater Skill Focus",
  "constant_prefix": "FEAT_GREATER_SKILL_FOCUS"
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
  "family": "skill_focus",
  "family_key": "skillfocus",
  "template": "masterfeats:skillfocus",
  "apply_after_modules": true,
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  },
  "label_prefix": "SKILL_FOCUS",
  "name_prefix": "Skill Focus",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "template_fields": ["FEAT", "MASTERFEAT"]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "0\t****\t****\t****\t****\t****\n") {
		t.Fatalf("expected manual null row 0 to be preserved, got:\n%s", text)
	}
	if !strings.Contains(text, "11\tGREATER_SKILL_FOCUS_PROFESSION_HERBALIST\t16777216\t11\t0\tFEAT_GREATER_SKILL_FOCUS_PROFESSION_HERBALIST\n") {
		t.Fatalf("expected greater skill focus to regenerate after base boundary, got:\n%s", text)
	}
	if !strings.Contains(text, "12\tSKILL_FOCUS_PROFESSION_HERBALIST\t****\t****\t0\tFEAT_SKILL_FOCUS_PROFESSION_HERBALIST\n") {
		t.Fatalf("expected skill focus to regenerate after base boundary, got:\n%s", text)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"feat:skillfocus_profession_herbalist": 12`) {
		t.Fatalf("expected generated skill focus lock id to be regenerated at row 12, got:\n%s", string(lockRaw))
	}
	if !strings.Contains(string(lockRaw), `"feat:greaterskillfocus_profession_herbalist": 11`) {
		t.Fatalf("expected generated greater skill focus lock id to be regenerated at row 11, got:\n%s", string(lockRaw))
	}
}

func TestBuildOverrideAsteriskKeyRemovesIdentityWithoutNullingRow(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 7, "key": "dense:old", "Label": "Old", "Value": "old"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
  "dense:old": 7
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "nullify.json"), `{
  "overrides": [
    {
      "id": 7,
      "key": "****",
      "Label": "ShouldNotLeak",
      "Value": "still-not-kept"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	if !strings.Contains(string(got), "7\tShouldNotLeak\tstill-not-kept\n") {
		t.Fatalf("expected override key \"****\" to remove identity without blanking row 7, got:\n%s", string(got))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
	if err != nil {
		t.Fatalf("read lock: %v", err)
	}
	if strings.Contains(string(lockRaw), `"dense:old"`) {
		t.Fatalf("expected nullified row key to be pruned from lock data, got:\n%s", string(lockRaw))
	}
}

func TestBuildCanonicalBaseitemsMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "baseitems", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "tlk"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "baseitems.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\\n\\n")
    f.write("Name\\tlabel\\tMaxRange\\n")
    f.write("0\\t106\\tshortsword\\t100\\n")
    for row_id in range(1, 17):
        f.write(f"{row_id}\\t****\\t****\\t****\\n")
    f.write("17\\t500\\thelmet\\t255\\n")
    for row_id in range(18, 113):
        f.write(f"{row_id}\\t****\\t****\\t****\\n")
    f.write("113\\tCoin\\tCoin\\t100\\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "base.json"), `{
  "columns": ["Name", "label", "MaxRange"],
  "rows": [
    {"key": "baseitems:shortsword", "id": 0, "Name": "106", "label": "shortsword", "MaxRange": "100"},
    {"key": "baseitems:helmet", "id": 17, "Name": "500", "label": "helmet", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "lock.json"), `{
  "baseitems:shortsword": 0,
  "baseitems:helmet": 17,
  "baseitems:coin": 113
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "blunderbuss.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "coin.json"), `{
  "entries": {
    "baseitems:coin": {
      "Name": { "tlk": "baseitems:coin.name" },
      "label": "Coin",
      "MaxRange": "100"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "estoc.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "heavymace.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_baseitems.json"), `{
  "overrides": [
    {"key": "baseitems:shortsword", "id": 0, "label": "shortsword"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_cloak255.json"), `{"overrides": []}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "ovr_helmet255.json"), `{
  "overrides": [
    {"key": "baseitems:helmet", "id": 17, "MaxRange": "255"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "maulandfalchion.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "baseitems", "modules", "shortspear.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "base.json"), `{
  "language": "en",
  "entries": {
    "baseitems:coin.name": { "text": "Coin" }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{
  "baseitems:coin.name": 70000
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read native baseitems.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read reference baseitems.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("baseitems output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestBuildUsesReferenceLockIDsForUnmigratedKeyRefs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
  "output": "masterfeats.2da",
  "columns": ["LABEL", "STRREF", "DESCRIPTION", "MinLevelClass"],
  "rows": [
    {"id": 0, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "****", "DESCRIPTION": "****", "MinLevelClass": {"id": "classes:fighter"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:weaponspecialization":0}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes"))
	writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "lock.json"), `{"classes:fighter":4}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
	if err != nil {
		t.Fatalf("read masterfeats.2da: %v", err)
	}
	want := "2DA V2.0\n\nLABEL\tSTRREF\tDESCRIPTION\tMinLevelClass\n0\tWeaponSpecialization\t****\t****\t4\n"
	if string(got) != want {
		t.Fatalf("unexpected masterfeats.2da output:\n%s", string(got))
	}
}

func TestBuildBaseDatasetAllowsModuleDeclaredColumns(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "custom", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "base.json"), `{
  "output": "custom.2da",
  "columns": ["Label"],
  "rows": [
    {"id": 0, "key": "custom:a", "Label": "A"},
    {"id": 1, "key": "custom:b", "Label": "B"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "custom", "modules", "extend.json"), `{
  "columns": ["Value"],
  "overrides": [
    {"id": 1, "Value": "2"}
  ],
  "entries": {
    "custom:c": {
      "Label": "C",
      "Value": "3"
    }
  }
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
	if err != nil {
		t.Fatalf("read custom.2da: %v", err)
	}
	want := "2DA V2.0\n\nLabel\tValue\n0\tA\t****\n1\tB\t2\n2\tC\t3\n"
	if string(got) != want {
		t.Fatalf("unexpected custom.2da output:\n%s", string(got))
	}
}

func TestBuildInjectsExpansionDataIntoTargetDataset(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
  "output": "portraits.2da",
  "columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
  "rows": [
    {"id": 0, "BaseResRef": "****", "Sex": 4}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
  "output": "appearance.2da",
  "columns": ["LABEL", "PORTRAIT"],
  "rows": [
    {"key": "appearance:crawlingclaw", "id": 0, "LABEL": "Crawling Claw", "PORTRAIT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{"appearance:crawlingclaw":0}`)
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_crawlingclaw.json"), `{
  "entries": {
    "appearance:crawlingclaw": {
      "LABEL": "Crawling Claw",
      "PORTRAIT": {
        "value": "po_cclaw_",
        "data": {
          "portraits": {
            "key": "portraits:cclaw_",
            "BaseResRef": "cclaw_",
            "Sex": 4,
            "Race": 10,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")

	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	appearanceBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "appearance.2da"))
	if err != nil {
		t.Fatalf("read appearance.2da: %v", err)
	}
	if !strings.Contains(string(appearanceBytes), "po_cclaw_") {
		t.Fatalf("expected PORTRAIT value in appearance.2da, got:\n%s", string(appearanceBytes))
	}

	portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
	if err != nil {
		t.Fatalf("read portraits.2da: %v", err)
	}
	text := string(portraitsBytes)
	if !strings.Contains(text, "cclaw_") || !strings.Contains(text, "Sex\t") || !strings.Contains(text, "Race\t") {
		t.Fatalf("expected injected portrait data in portraits.2da, got:\n%s", text)
	}
	if !strings.Contains(text, "4\t10\t") {
		t.Fatalf("expected Sex=4 Race=10 in portraits.2da, got:\n%s", text)
	}

	lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
	if err != nil {
		t.Fatalf("read portraits lock.json: %v", err)
	}
	lockText := string(lockBytes)
	if !strings.Contains(lockText, "portraits:cclaw_") {
		t.Fatalf("expected lockfile entry for portraits:cclaw_, got:\n%s", lockText)
	}
}

func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
  "output": "portraits.2da",
  "columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
  "rows": [
    {"id": 0, "BaseResRef": "****", "Sex": 4}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
  "output": "appearance.2da",
  "columns": ["LABEL", "PORTRAIT"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), "{}")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zombieknight.json"), `{
  "entries": {
    "appearance:zombieknight3": {
      "LABEL": "Zombie Knight 3",
      "PORTRAIT": {
        "value": "po_zk_",
        "data": {
          "portraits": {
            "key": "portraits:zk_",
            "BaseResRef": "zk_",
            "Sex": 4,
            "Race": 23,
            "Plot": 0
          }
        }
      }
    },
    "appearance:zombieknight4": {
      "LABEL": "Zombie Knight 4",
      "PORTRAIT": {
        "value": "po_zk_",
        "data": {
          "portraits": {
            "key": "portraits:zk_",
            "BaseResRef": "zk_",
            "Sex": 4,
            "Race": 23,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")

	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
	if err != nil {
		t.Fatalf("read portraits.2da: %v", err)
	}
	text := string(portraitsBytes)
	lines := strings.Split(text, "\n")
	zkCount := 0
	for _, line := range lines {
		if strings.Contains(line, "zk_") {
			zkCount++
		}
	}
	if zkCount != 1 {
		t.Fatalf("expected exactly 1 portrait row with zk_, got %d:\n%s", zkCount, text)
	}
}

func TestBuildExpansionDataIsGlobalAndPrunesStaleInjectedLocks(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "source", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "target"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	writeFile(t, filepath.Join(root, "topdata", "data", "source", "base.json"), `{
  "output": "source.2da",
  "columns": ["Label", "Link"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "source", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "target", "base.json"), `{
  "output": "target.2da",
  "columns": ["Label", "Value"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "target", "lock.json"), "{}\n")

	modulePath := filepath.Join(root, "topdata", "data", "source", "modules", "inject.json")
	writeFile(t, modulePath, `{
  "entries": {
    "source:one": {
      "Label": "One",
      "Link": {
        "value": "target-one",
        "data": {
          "target": {
            "key": "target:one",
            "Label": "Target One",
            "Value": "one"
          }
        }
      }
    }
  }
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	targetBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
	if err != nil {
		t.Fatalf("read target.2da: %v", err)
	}
	if !strings.Contains(string(targetBytes), "0\t\"Target One\"\tone\n") {
		t.Fatalf("expected injected target row, got:\n%s", string(targetBytes))
	}
	lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
	if err != nil {
		t.Fatalf("read target lock.json: %v", err)
	}
	if !strings.Contains(string(lockBytes), `"target:one": 0`) {
		t.Fatalf("expected lock entry for injected target row, got:\n%s", string(lockBytes))
	}

	writeFile(t, modulePath, `{
  "entries": {
    "source:one": {
      "Label": "One",
      "Link": {
        "value": "target-two",
        "data": {
          "target": {
            "key": "target:two",
            "Label": "Target Two",
            "Value": "two"
          }
        }
      }
    }
  }
}`+"\n")
	result, err = BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative after alteration failed: %v", err)
	}
	targetBytes, err = os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
	if err != nil {
		t.Fatalf("read altered target.2da: %v", err)
	}
	targetText := string(targetBytes)
	if strings.Contains(targetText, "Target One") || !strings.Contains(targetText, "0\t\"Target Two\"\ttwo\n") {
		t.Fatalf("expected altered injected target row only, got:\n%s", targetText)
	}
	lockBytes, err = os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
	if err != nil {
		t.Fatalf("read altered target lock.json: %v", err)
	}
	lockText := string(lockBytes)
	if strings.Contains(lockText, "target:one") || !strings.Contains(lockText, `"target:two": 0`) {
		t.Fatalf("expected stale injected lock to be pruned after alteration, got:\n%s", lockText)
	}

	if err := os.Remove(modulePath); err != nil {
		t.Fatalf("remove injection module: %v", err)
	}
	result, err = BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative after deletion failed: %v", err)
	}
	targetBytes, err = os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
	if err != nil {
		t.Fatalf("read deleted target.2da: %v", err)
	}
	if strings.Contains(string(targetBytes), "Target Two") {
		t.Fatalf("expected injected target row to be removed after module deletion, got:\n%s", string(targetBytes))
	}
	lockBytes, err = os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
	if err != nil {
		t.Fatalf("read deleted target lock.json: %v", err)
	}
	if strings.Contains(string(lockBytes), "target:two") {
		t.Fatalf("expected injected target lock to be pruned after module deletion, got:\n%s", string(lockBytes))
	}
}

func TestBuildExpansionDataPreservesExistingLockfileIDs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")

	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
  "output": "portraits.2da",
  "columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
  "rows": [
    {"id": 0, "BaseResRef": "****", "Sex": 4},
    {"id": 1, "BaseResRef": "existing_", "Sex": 2, "key": "portraits:existing"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "placeables.json"), `{
  "entries": {
    "portraits:plc_scala": {
      "BaseResRef": "plc_scala",
      "Sex": 4,
      "Race": 10,
      "Plot": 0
    },
    "portraits:plc_scalb": {
      "BaseResRef": "plc_scalb",
      "Sex": 4,
      "Race": 10,
      "Plot": 0
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{
  "portraits:existing": 1,
  "portraits:grue_air_": 16632,
  "portraits:grue_fire_": 16633,
  "portraits:eyeball_": 16634,
  "portraits:zk_": 16635,
  "portraits:plc_scala": 16636,
  "portraits:plc_scalb": 16637
}`)
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
  "output": "appearance.2da",
  "columns": ["LABEL", "PORTRAIT"],
  "rows": [
    {"key": "appearance:grue_air", "id": 0, "LABEL": "Air Grue", "PORTRAIT": "****"},
    {"key": "appearance:grue_fire", "id": 1, "LABEL": "Fire Grue", "PORTRAIT": "****"},
    {"key": "appearance:eyeball", "id": 2, "LABEL": "Eyeball", "PORTRAIT": "****"},
    {"key": "appearance:zk", "id": 3, "LABEL": "Zombie Knight", "PORTRAIT": "****"},
    {"key": "appearance:displacer", "id": 4, "LABEL": "Displacer", "PORTRAIT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{
  "appearance:grue_air": 0,
  "appearance:grue_fire": 1,
  "appearance:eyeball": 2,
  "appearance:zk": 3,
  "appearance:displacer": 4
}`)
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_grues.json"), `{
  "entries": {
    "appearance:grue_air": {
      "LABEL": "Air Grue",
      "PORTRAIT": {
        "value": "po_grue_air_",
        "data": {
          "portraits": {
            "key": "portraits:grue_air_",
            "BaseResRef": "grue_air_",
            "Sex": 4,
            "Race": 10,
            "Plot": 0
          }
        }
      }
    },
    "appearance:grue_fire": {
      "LABEL": "Fire Grue",
      "PORTRAIT": {
        "value": "po_grue_fire_",
        "data": {
          "portraits": {
            "key": "portraits:grue_fire_",
            "BaseResRef": "grue_fire_",
            "Sex": 4,
            "Race": 10,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_eyeball.json"), `{
  "entries": {
    "appearance:eyeball": {
      "LABEL": "Eyeball",
      "PORTRAIT": {
        "value": "po_eyeball_",
        "data": {
          "portraits": {
            "key": "portraits:eyeball_",
            "BaseResRef": "eyeball_",
            "Sex": 4,
            "Race": 10,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zk.json"), `{
  "entries": {
    "appearance:zk": {
      "LABEL": "Zombie Knight",
      "PORTRAIT": {
        "value": "po_zk_",
        "data": {
          "portraits": {
            "key": "portraits:zk_",
            "BaseResRef": "zk_",
            "Sex": 4,
            "Race": 10,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_displacer.json"), `{
  "entries": {
    "appearance:displacer": {
      "LABEL": "Displacer",
      "PORTRAIT": {
        "value": "po_displacer1_",
        "data": {
          "portraits": {
            "key": "portraits:displacer1_",
            "BaseResRef": "displacer1_",
            "Sex": 4,
            "Race": 19,
            "Plot": 0
          }
        }
      }
    }
  }
}`+"\n")

	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
	if err != nil {
		t.Fatalf("read portraits lock.json: %v", err)
	}
	lockText := string(lockBytes)

	// All existing lockfile entries must preserve their IDs — no shifting
	expected := map[string]string{
		"portraits:grue_air_":  "16632",
		"portraits:grue_fire_": "16633",
		"portraits:eyeball_":   "16634",
		"portraits:zk_":        "16635",
		"portraits:plc_scala":  "16636",
		"portraits:plc_scalb":  "16637",
	}
	for key, wantID := range expected {
		entry := fmt.Sprintf(`"%s": %s`, key, wantID)
		if !strings.Contains(lockText, entry) {
			t.Fatalf("lockfile entry %s was not preserved:\n%s", entry, lockText)
		}
	}

	// New entry must NOT conflict with any existing ID
	var lockData map[string]int
	if err := json.Unmarshal([]byte(lockText), &lockData); err != nil {
		t.Fatalf("unmarshal lock.json: %v", err)
	}
	newID, ok := lockData["portraits:displacer1_"]
	if !ok {
		t.Fatalf("expected lockfile entry for portraits:displacer1_, got:\n%s", lockText)
	}
	for key, id := range lockData {
		if key != "portraits:displacer1_" && id == newID {
			t.Fatalf("new portrait displacer1_ got ID %d which conflicts with existing %s=%d", newID, key, id)
		}
	}
	if newID < 2 {
		t.Fatalf("new portrait displacer1_ got unreasonably low ID %d", newID)
	}

	portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
	if err != nil {
		t.Fatalf("read portraits.2da: %v", err)
	}
	text := string(portraitsBytes)
	if !strings.Contains(text, "displacer1_") {
		t.Fatalf("expected injected portrait in portraits.2da, got:\n%s", text)
	}
}

func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
  "output": "placeables.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "Shelf", "meta": {"unknown": {}}}
  ]
}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected metadata validation error, got %#v", report.Diagnostics)
	}
	if !strings.Contains(diagnosticsText(report.Diagnostics), `unknown metadata key "unknown"`) {
		t.Fatalf("expected unknown metadata diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
	}
}

func TestValidateProjectRejectsUnknownMetadataKeyCaseInsensitive(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
  "output": "placeables.2da",
  "columns": ["Label"],
  "rows": [
    {"id": 0, "Label": "Shelf", "Meta": {"unknown": {}}}
  ]
}`+"\n")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected metadata validation error, got %#v", report.Diagnostics)
	}
	if !strings.Contains(diagnosticsText(report.Diagnostics), `unknown metadata key "unknown"`) {
		t.Fatalf("expected unknown metadata diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
	}
}

func TestBuildNativeTreatsMetaCaseInsensitive(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
  "output": "dense.2da",
  "columns": ["Label"],
  "rows": [
    {"id": 0, "Label": "Base", "Meta": {"wiki": {"status": "base"}}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{"dense:entry":1}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
  "entries": {
    "dense:entry": {"Label": "Entry", "Meta": {"wiki": {"status": "entry"}}}
  },
  "overrides": [
    {"id": 0, "key": "dense:base", "Label": "BaseOverride", "Meta": {"wiki": {"status": "override"}}}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
	if err != nil {
		t.Fatalf("read dense.2da: %v", err)
	}
	if string(got) != "2DA V2.0\n\nLabel\n0\tBaseOverride\n1\tEntry\n" {
		t.Fatalf("unexpected dense.2da output:\n%s", string(got))
	}
	if strings.Contains(string(got), "meta") {
		t.Fatalf("expected metadata to stay out of dense.2da, got:\n%s", string(got))
	}
}

func TestBuildCanonicalAppearanceMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "appearance", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "appearance.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("LABEL\tPORTRAIT\n")
    f.write("0\tDwarf\t****\n")
    f.write("15105\tBlack Lagoon Reaver\tpo_cotbl\n")
    f.write("15111\tBlack Lagoon Reaver, Swimming\tpo_cotbl\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "base.json"), `{
  "columns": ["LABEL", "PORTRAIT"],
  "rows": [
    {"key": "appearance:dwarf", "id": 0, "LABEL": "Dwarf", "PORTRAIT": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "lock.json"), `{
  "appearance:blacklagoonreaver": 15105,
  "appearance:blacklagoonreaverswimming": 15111
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "cotblreaver.json"), `{
  "entries": {
    "appearance:blacklagoonreaver": {"LABEL": "Black Lagoon Reaver", "PORTRAIT": "po_cotbl"},
    "appearance:blacklagoonreaverswimming": {"LABEL": "Black Lagoon Reaver, Swimming", "PORTRAIT": "po_cotbl"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "crawlingclaw.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "halfogre.json"), `{"entries": {}}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "zombieknight.json"), `{"entries": {}}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "appearance.2da"))
	if err != nil {
		t.Fatalf("read native appearance.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "appearance.2da"))
	if err != nil {
		t.Fatalf("read reference appearance.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("appearance output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacyPlaceables(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "placeables", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "base.json"), `{
  "columns": ["Label", "StrRef", "ModelName", "LightColor", "LightOffsetX", "LightOffsetY", "LightOffsetZ", "SoundAppType", "ShadowSize", "BodyBag", "LowGore", "Reflection", "Static"],
  "rows": [
    {"key": "placeables:armoire", "id": 0, "Label": "Armoire", "StrRef": "5645", "ModelName": "PLC_A01", "SoundAppType": "13", "ShadowSize": "1", "BodyBag": "0", "Static": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "lock.json"), `{
  "placeables:armoire": 0,
  "placeables:tmp_crappile01": 16500
}`+"\n")
	for _, name := range []string{
		"add_ccfeb2019plcs.json",
		"add_cepcarpets.json",
		"add_cepfloors.json",
		"add_cepnwn2library.json",
		"add_nwic.json",
		"add_ori.json",
		"add_sic11.json",
		"add_tapr.json",
		"add_tdfloors.json",
		"add_undecals.json",
		"add_witcher1.json",
		"add_witcher2.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", "add_avernostragarbage.json"), `{
  "entries": {
    "placeables:tmp_crappile01": {
      "Label": "Decoration: Pile 1 (Avernostra)",
      "ModelName": "tmp_crappile01",
      "Static": 1
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", "ovr_vanillaplcsorts.json"), `{
  "overrides": [
    {"key": "placeables:armoire", "id": 0, "Label": "Furniture: Armoire (Bioware)", "StrRef": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 16 {
		t.Fatalf("expected placeables files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "placeables", "base.json"),
		filepath.Join(root, "topdata", "data", "placeables", "lock.json"),
		filepath.Join(root, "topdata", "data", "placeables", "modules", "add_avernostragarbage.json"),
		filepath.Join(root, "topdata", "data", "placeables", "modules", "ovr_vanillaplcsorts.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported placeables path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "placeables", "base.json"))
	if err != nil {
		t.Fatalf("read placeables base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "placeables.2da"`) {
		t.Fatalf("expected imported placeables output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "placeables", "lock.json"))
	if err != nil {
		t.Fatalf("read placeables lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"placeables:tmp_crappile01": 16500`) {
		t.Fatalf("expected imported placeables lock IDs, got:\n%s", string(lockRaw))
	}
}

func TestNormalizeProjectImportsLegacyRuleset(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "ruleset", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "base.json"), `{
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "Label": "****", "Value": "****"},
    {"id": 7, "Label": "CALLED_SHOT_TO_HIT_MODIFIER", "Value": "-4"},
    {"id": 21, "Label": "GOOD_AIM_MODIFIER", "Value": "1"},
    {"id": 100, "Label": "HASTE_DODGE_AC_INCREASE_AMOUNT", "Value": "0"},
    {"id": 101, "Label": "HASTED_SPELL_CONJURE_TIME_MODIFIER", "Value": "0.5f"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "modules", "ovr_single.json"), `{
  "overrides": [{"match": {"Label": "GOOD_AIM_MODIFIER"}, "Value": "2"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "modules", "ovr_array.json"), `{
  "overrides": [{"match": {"Label": ["HASTE_DODGE_AC_INCREASE_AMOUNT", "HASTED_SPELL_CONJURE_TIME_MODIFIER"]}, "Value": "1.0f"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 1 {
		t.Fatalf("expected ruleset import, got %#v", result)
	}
	rulesetPath := filepath.Join(root, "topdata", "data", "ruleset", "ruleset.json")
	if _, err := os.Stat(rulesetPath); err != nil {
		t.Fatalf("expected imported ruleset file: %v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "ruleset", "lock.json")); !os.IsNotExist(err) {
		t.Fatalf("expected no ruleset lock file, got %v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "topdata", "data", "ruleset", "modules")); !os.IsNotExist(err) {
		t.Fatalf("expected no ruleset modules dir, got %v", err)
	}
	raw, err := os.ReadFile(rulesetPath)
	if err != nil {
		t.Fatalf("read ruleset: %v", err)
	}
	text := string(raw)
	if !strings.Contains(text, `"output": "ruleset.2da"`) ||
		!strings.Contains(text, `"Label": "GOOD_AIM_MODIFIER"`) ||
		!strings.Contains(text, `"Value": "2"`) ||
		!strings.Contains(text, `"Label": "HASTED_SPELL_CONJURE_TIME_MODIFIER"`) ||
		!strings.Contains(text, `"Value": "1.0f"`) {
		t.Fatalf("unexpected imported ruleset:\n%s", text)
	}
}

func TestBuildSupportsCanonicalRuleset(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "ruleset.json"), `{
  "output": "ruleset.2da",
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "Label": "****", "Value": "****"},
    {"id": 21, "Label": "GOOD_AIM_MODIFIER", "Value": "2"},
    {"id": 100, "Label": "HASTE_DODGE_AC_INCREASE_AMOUNT", "Value": "1.0f"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "ruleset.2da"))
	if err != nil {
		t.Fatalf("read ruleset.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "Label\tValue\n") ||
		!strings.Contains(text, "21\tGOOD_AIM_MODIFIER\t2\n") ||
		!strings.Contains(text, "100\tHASTE_DODGE_AC_INCREASE_AMOUNT\t1.0f\n") {
		t.Fatalf("unexpected ruleset.2da output:\n%s", text)
	}
}

func TestBuildCanonicalRulesetMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "ruleset", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "ruleset.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tValue\n")
    f.write("0\t****\t****\n")
    f.write("21\tGOOD_AIM_MODIFIER\t2\n")
    f.write("100\tHASTE_DODGE_AC_INCREASE_AMOUNT\t1.0f\n")
    f.write("101\tHASTED_SPELL_CONJURE_TIME_MODIFIER\t1.0f\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "base.json"), `{
  "columns": ["Label", "Value"],
  "rows": [
    {"id": 0, "Label": "****", "Value": "****"},
    {"id": 21, "Label": "GOOD_AIM_MODIFIER", "Value": "1"},
    {"id": 100, "Label": "HASTE_DODGE_AC_INCREASE_AMOUNT", "Value": "0"},
    {"id": 101, "Label": "HASTED_SPELL_CONJURE_TIME_MODIFIER", "Value": "0.5f"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "modules", "ovr_single.json"), `{
  "overrides": [{"match": {"Label": "GOOD_AIM_MODIFIER"}, "Value": "2"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "ruleset", "modules", "ovr_array.json"), `{
  "overrides": [{"match": {"Label": ["HASTE_DODGE_AC_INCREASE_AMOUNT", "HASTED_SPELL_CONJURE_TIME_MODIFIER"]}, "Value": "1.0f"}]
}`+"\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 1 {
		t.Fatalf("expected ruleset import, got %#v", result)
	}
	if _, err := Build(testProject(root), nil); err != nil {
		t.Fatalf("Build failed: %v", err)
	}
	compare, err := CompareReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("CompareReference failed: %v", err)
	}
	if compare.NativeMismatch != 0 || compare.NativePass != 1 {
		t.Fatalf("unexpected compare result: %#v", compare)
	}
}

func TestNormalizeProjectImportsLegacySkills(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "skills"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{
  "skills:athletics.name": 1000,
  "skills:athletics.description": 1001,
  "skills:craft_armorsmithing.name": 1002,
  "skills:craft_armorsmithing.description": 1003,
  "skills:knowledge_arcana.name": 1004
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "skills", "skills.json"), `{
  "entries": {
    "skills:athletics.name": {"text": "Athletics"},
    "skills:athletics.description": {"text": "Athletics description"},
    "skills:craft_armorsmithing.name": {"text": "Craft Armor Smithing"},
    "skills:craft_armorsmithing.description": {"text": "Craft armor description"},
    "skills:knowledge_arcana.name": {"text": "Knowledge Arcana"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "base.json"), `{
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"key": "skills:animalempathy", "id": 0, "Label": "AnimalEmpathy", "Name": "269", "Description": "344", "Icon": "isk_aniemp", "Untrained": "0", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "1", "Constant": "SKILL_ANIMAL_EMPATHY", "HostileSkill": "1", "HideFromLevelUp": "0"},
    {"key": "skills:concentration", "id": 1, "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:discipline", "id": 3, "Label": "Discipline", "Name": "343", "Description": "347", "Icon": "isk_discipline", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_DISCIPLINE", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:tumble", "id": 25, "Label": "Tumble", "Name": "280", "Description": "356", "Icon": "isk_tumble", "Untrained": "1", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TUMBLE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "lock.json"), `{
  "skills:craft_armorsmithing": 25,
  "skills:athletics": 28,
  "skills:knowledge_arcana": 40
}`+"\n")
	for _, name := range []string{
		"add_disguise.json",
		"add_linguistics.json",
		"add_perception.json",
		"add_scriptcraft.json",
		"add_sensemotive.json",
		"add_stealth.json",
		"add_survival.json",
		"add_userope.json",
		"ovr_acrobatics.json",
		"ovr_animalhandling.json",
		"ovr_appraise.json",
		"ovr_concentration.json",
		"ovr_disabledevice.json",
		"ovr_heal.json",
		"ovr_hiddenskills.json",
		"ovr_influence.json",
		"ovr_openlock.json",
		"ovr_parry.json",
		"ovr_searchtowis.json",
		"ovr_sleightofhand.json",
		"ovr_taunttointimidate.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", name), `{"overrides": []}`+"\n")
	}
	for _, name := range []string{
		"add_craftalchemy.json",
		"add_craftcooking.json",
		"add_craftjewelry.json",
		"add_craftleatherworking.json",
		"add_craftstonework.json",
		"add_crafttextiles.json",
		"ovr_crafttinkering.json",
		"ovr_craftweaponsmithing.json",
		"ovr_craftwoodworking.json",
	} {
		payload := `{"entries": {}}`
		if strings.HasPrefix(name, "ovr_") {
			payload = `{"overrides": []}`
		}
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft", name), payload+"\n")
	}
	for _, name := range []string{
		"add_knowledgearchitecture.json",
		"add_knowledgedungeoneering.json",
		"add_knowledgegeography.json",
		"add_knowledgehistory.json",
		"add_knowledgelocal.json",
		"add_knowledgenature.json",
		"add_knowledgenobility.json",
		"add_knowledgeplanar.json",
		"add_knowledgereligion.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "add_athletics.json"), `{
  "entries": {
    "skills:athletics": {
      "Label": "Athletics",
      "Name": {"tlk": "skills:athletics.name"},
      "Description": {"tlk": "skills:athletics.description"},
      "Icon": "isk_athletics",
      "Untrained": "1",
      "KeyAbility": "STR",
      "ArmorCheckPenalty": "1",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_ATHLETICS",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"), `{
  "overrides": [
    {
      "key": "skills:craft_armorsmithing",
      "id": 25,
      "Label": "Craftarmorsmithing",
      "Name": {"tlk": "skills:craft_armorsmithing.name"},
      "Description": {"tlk": "skills:craft_armorsmithing.description"},
      "Icon": "isk_x2carm",
      "Untrained": "0",
      "Constant": "SKILL_CRAFT_ARMORSMITHING",
      "meta": {"wiki": {"status": "new"}},
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge", "add_knowledgearcana.json"), `{
  "entries": {
    "skills:knowledge_arcana": {
      "Label": "Knowledgearcana",
      "Name": {"tlk": "skills:knowledge_arcana.name"},
      "Icon": "isk_knarcana",
      "Untrained": "1",
      "KeyAbility": "INT",
      "ArmorCheckPenalty": "0",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_KNOWLEDGE_ARCANA",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "rmv_removedskills.json"), `{
  "overrides": [
    {
      "key": null,
      "id": 3,
      "Label": "Discipline_REMOVED",
      "Constant": "****",
      "AllClassesCanUse": "0",
      "HideFromLevelUp": "1"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 6 {
		t.Fatalf("expected skills files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "skills", "base.json"),
		filepath.Join(root, "topdata", "data", "skills", "lock.json"),
		filepath.Join(root, "topdata", "data", "skills", "modules", "add_athletics.json"),
		filepath.Join(root, "topdata", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"),
		filepath.Join(root, "topdata", "data", "skills", "modules", "knowledge", "add_knowledgearcana.json"),
		filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported skills path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "base.json"))
	if err != nil {
		t.Fatalf("read skills base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "skills.2da"`) {
		t.Fatalf("expected imported skills output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "lock.json"))
	if err != nil {
		t.Fatalf("read skills lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"skills:athletics": 28`) || !strings.Contains(string(lockRaw), `"skills:knowledge_arcana": 40`) {
		t.Fatalf("expected imported skills lock ids, got:\n%s", string(lockRaw))
	}
	moduleRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "modules", "add_athletics.json"))
	if err != nil {
		t.Fatalf("read skills athletics module: %v", err)
	}
	if !strings.Contains(string(moduleRaw), `"text": "Athletics"`) || !strings.Contains(string(moduleRaw), `"text": "Athletics description"`) {
		t.Fatalf("expected normalized inline TLK payloads in athletics module, got:\n%s", string(moduleRaw))
	}
	craftRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"))
	if err != nil {
		t.Fatalf("read skills craft override: %v", err)
	}
	if !strings.Contains(string(craftRaw), `"text": "Craft Armor Smithing"`) || !strings.Contains(string(craftRaw), `"text": "Craft armor description"`) {
		t.Fatalf("expected normalized inline TLK payloads in craft override, got:\n%s", string(craftRaw))
	}
}

func TestBuildSupportsCanonicalSkills(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules", "craft"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules", "knowledge"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"key": "skills:animalempathy", "id": 0, "Label": "AnimalEmpathy", "Name": "269", "Description": "344", "Icon": "isk_aniemp", "Untrained": "0", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "1", "Constant": "SKILL_ANIMAL_EMPATHY", "HostileSkill": "1", "HideFromLevelUp": "0"},
    {"key": "skills:concentration", "id": 1, "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:discipline", "id": 3, "Label": "Discipline", "Name": "343", "Description": "347", "Icon": "isk_discipline", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_DISCIPLINE", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:craft_armorsmithing", "id": 25, "Label": "Tumble", "Name": "280", "Description": "356", "Icon": "isk_tumble", "Untrained": "1", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TUMBLE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
  "skills:craft_armorsmithing": 25,
  "skills:athletics": 28,
  "skills:knowledge_arcana": 40
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "add_athletics.json"), `{
  "entries": {
    "skills:athletics": {
      "Label": "Athletics",
      "Name": "1000",
      "Description": "1001",
      "Icon": "isk_athletics",
      "Untrained": "1",
      "KeyAbility": "STR",
      "ArmorCheckPenalty": "1",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_ATHLETICS",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"), `{
  "overrides": [
    {
      "key": "skills:craft_armorsmithing",
      "id": 25,
      "Label": "Craftarmorsmithing",
      "Name": "1002",
      "Description": "1003",
      "Icon": "isk_x2carm",
      "Untrained": "0",
      "Constant": "SKILL_CRAFT_ARMORSMITHING",
      "meta": {"wiki": {"status": "new"}},
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "knowledge", "add_knowledgearcana.json"), `{
  "entries": {
    "skills:knowledge_arcana": {
      "Label": "Knowledgearcana",
      "Name": "1004",
      "Icon": "isk_knarcana",
      "Untrained": "1",
      "KeyAbility": "INT",
      "ArmorCheckPenalty": "0",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_KNOWLEDGE_ARCANA",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
  "overrides": [
    {
      "key": null,
      "id": 3,
      "Label": "Discipline_REMOVED",
      "Constant": "****",
      "AllClassesCanUse": "0",
      "HideFromLevelUp": "1"
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skills.2da"))
	if err != nil {
		t.Fatalf("read skills.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "Label\tName\tDescription\tIcon") ||
		!strings.Contains(text, "3\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n") ||
		!strings.Contains(text, "25\tCraftarmorsmithing\t1002\t1003\tisk_x2carm\t0\tDEX\t1\t1\t****\t****\tSKILL_CRAFT_ARMORSMITHING\t0\t0\n") ||
		!strings.Contains(text, "28\tAthletics\t1000\t1001\tisk_athletics\t1\tSTR\t1\t1\t****\t****\tSKILL_ATHLETICS\t0\t0\n") ||
		!strings.Contains(text, "40\tKnowledgearcana\t1004\t****\tisk_knarcana\t1\tINT\t0\t1\t****\t****\tSKILL_KNOWLEDGE_ARCANA\t0\t0\n") {
		t.Fatalf("unexpected skills.2da output:\n%s", text)
	}
}

func TestBuildGeneratedSkillFocusUsesOverriddenCanonicalSkillKey(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{
  "family": "skill_focus",
  "family_key": "skillfocus",
  "template": "masterfeats:skillfocus",
  "name_prefix": "Skill Focus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "default_fields": {
    "CRValue": "0.5",
    "ReqSkillMinRanks": "1",
    "ALLCLASSESCANUSE": "1",
    "TOOLSCATEGORIES": "6",
    "PreReqEpic": "0",
    "ReqAction": "1"
  },
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  }
}` + "\n",
		"greater_skill_focus.json": `{
  "family": "greater_skill_focus",
  "family_key": "greaterskillfocus",
  "template": "masterfeats:greaterskillfocus",
  "name_prefix": "Greater Skill Focus",
  "label_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
  "default_fields": {
    "CRValue": "0.2",
    "ReqSkillMinRanks": "15",
    "ALLCLASSESCANUSE": "1",
    "TOOLSCATEGORIES": "6",
    "PreReqEpic": "0",
    "ReqAction": "1"
  },
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  }
}` + "\n",
	}, map[string]int{
		"feat:skillfocus_intimidate":        192,
		"feat:greaterskillfocus_intimidate": 1305,
	})
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "280", "Description": "356", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
  "skills:intimidate": 18
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
  "overrides": [
    {
      "id": 18,
      "key": "skills:intimidate",
      "Label": "Intimidate",
      "Constant": "SKILL_INTIMIDATE",
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "192\tFEAT_SKILL_FOCUS_INTIMIDATE\t") {
		t.Fatalf("expected generated skill focus intimidate row, got:\n%s", text)
	}
	if !strings.Contains(text, "1305\tFEAT_GREATER_SKILL_FOCUS_INTIMIDATE\t") {
		t.Fatalf("expected generated greater skill focus intimidate row, got:\n%s", text)
	}
	if strings.Contains(text, "FEAT_SKILL_FOCUS_TAUNT") || strings.Contains(text, "FEAT_GREATER_SKILL_FOCUS_TAUNT") {
		t.Fatalf("expected generated feat families to follow overridden canonical skill key, got:\n%s", text)
	}
}

func TestFeatGeneratedContextIgnoresExplicitlyRetiredFeatKeys(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{
  "family": "skill_focus",
  "family_key": "skill_focus",
  "template": "masterfeats:skillfocus",
  "name_prefix": "Skill Focus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "default_fields": {
    "CRValue": "0.5",
    "ReqSkillMinRanks": "1",
    "ALLCLASSESCANUSE": "1",
    "TOOLSCATEGORIES": "6",
    "PreReqEpic": "0",
    "ReqAction": "1"
  },
  "apply_after_modules": true,
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  }
}` + "\n",
	}, map[string]int{
		"feat:skill_focus_intimidate":      916,
		"feat:epic_skill_focus_intimidate": 918,
	})
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:intimidate":18}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": [
    "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
    "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
    "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
    "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
    "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
  ],
  "rows": [
    {"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"},
    {"id": 918, "key": "feat:epic_skill_focus_intimidate", "LABEL": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 15, "Constant": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden", "rmv_feat_intimidate.json"), `{
  "overrides": [
    {"id": 916, "null": true, "key": null},
    {"id": 918, "null": true, "key": null}
  ]
}`+"\n")

	datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
	if err != nil {
		t.Fatalf("discoverNativeDatasets failed: %v", err)
	}
	var featDataset nativeDataset
	found := false
	for _, dataset := range datasets {
		if dataset.Name == "feat" {
			featDataset = dataset
			found = true
			break
		}
	}
	if !found {
		t.Fatal("expected feat dataset to be discovered")
	}
	lockData, err := loadLockfile(featDataset.LockPath)
	if err != nil {
		t.Fatalf("load feat lock failed: %v", err)
	}
	ctx, err := newFeatGeneratedContext(featDataset, lockData)
	if err != nil {
		t.Fatalf("newFeatGeneratedContext failed: %v", err)
	}
	if ctx.featKeyExists("feat:skill_focus_intimidate") {
		t.Fatal("expected explicitly retired feat key to be absent from existing feat set")
	}
	if _, ok := ctx.lockData["feat:skill_focus_intimidate"]; ok {
		t.Fatalf("expected explicitly retired feat key to be absent from generated lock view, got %#v", ctx.lockData)
	}
}

func TestBuildGeneratedSkillFocusDoesNotReuseNulledFeatRow(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{
  "family": "skill_focus",
  "family_key": "skill_focus",
  "template": "masterfeats:skillfocus",
  "name_prefix": "Skill Focus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "default_fields": {
    "CRValue": "0.5",
    "ReqSkillMinRanks": "1",
    "ALLCLASSESCANUSE": "1",
    "TOOLSCATEGORIES": "6",
    "PreReqEpic": "0",
    "ReqAction": "1"
  },
  "apply_after_modules": true,
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  },
  "overrides": {
    "skills:intimidate": {
      "ICON": "ife_X2SkFInti"
    }
  }
}` + "\n",
	}, map[string]int{
		"feat:skill_focus_intimidate": 916,
	})
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "1", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:intimidate":18}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": [
    "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
    "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
    "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
    "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
    "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
  ],
  "rows": [
    {"id": 192, "key": "feat:skill_focus_taunt", "LABEL": "FEAT_SKILL_FOCUS_TAUNT", "REQSKILL": 18, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_TAUNT"},
    {"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden", "rmv_feat_intimidate.json"), `{
  "overrides": [
    {"id": 916, "null": true, "key": null}
  ]
}`+"\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
	if err != nil {
		t.Fatalf("read feat lock: %v", err)
	}
	lockText := string(lockRaw)
	if strings.Contains(lockText, `"feat:skill_focus_intimidate": 916`) {
		t.Fatalf("expected generated skill focus intimidate to avoid nulled row 916, got:\n%s", lockText)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
	if err != nil {
		t.Fatalf("read feat.2da: %v", err)
	}
	text := string(got)
	if strings.Contains(text, "916\tFEAT_SKILL_FOCUS_INTIMIDATE\t") {
		t.Fatalf("expected generated skill focus intimidate to avoid row 916, got:\n%s", text)
	}
	if !strings.Contains(text, "FEAT_SKILL_FOCUS_INTIMIDATE") {
		t.Fatalf("expected generated skill focus intimidate row, got:\n%s", text)
	}
}

func TestOverrideRequestsNullRowUsesExplicitNullFlag(t *testing.T) {
	if overrideRequestsNullRow(map[string]any{"key": nil}) {
		t.Fatal("key: null should not blank a row")
	}
	if overrideRequestsNullRow(map[string]any{"key": "****"}) {
		t.Fatal(`key: "****" should not blank a row`)
	}
	if !overrideRequestsNullRow(map[string]any{"null": true}) {
		t.Fatal(`null: true should blank a row`)
	}
}

func TestBuildSkillsKeyNullRemovesIdentityWithoutNullingRow(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "280", "Description": "356", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"},
    {"id": 24, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_X2Inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
  "overrides": [
    {
      "id": 18,
      "key": "skills:intimidate",
      "Label": "Intimidate",
      "Constant": "SKILL_INTIMIDATE",
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
  "overrides": [
    {
      "id": 24,
      "key": null,
      "Label": null,
      "Constant": null,
      "HideFromLevelUp": "1"
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "lock.json"))
	if err != nil {
		t.Fatalf("read skills lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"skills:intimidate": 18`) {
		t.Fatalf("expected remapped intimidate lock entry, got:\n%s", lockText)
	}
	if strings.Contains(lockText, `"skills:taunt"`) {
		t.Fatalf("expected taunt to be removed from lock, got:\n%s", lockText)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skills.2da"))
	if err != nil {
		t.Fatalf("read skills.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "24\t****\t8756\t8786\tisk_X2Inti\t1\tCHA\t0\t1\t****\t****\t****\t0\t1\n") {
		t.Fatalf("expected id 24 row to remain present but de-identified, got:\n%s", text)
	}
}

func TestBuildSpellsAllowsDuplicateBaseKeyToBeSplitAcrossTwoOverrideRenames(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
  "output": "spells.2da",
  "columns": ["Label", "Name", "SpellDesc"],
  "rows": [
    {"id": 443, "key": "spells:etherealness", "Label": "GreaterSanctuary", "Name": "2364", "SpellDesc": "2371"},
    {"id": 724, "key": "spells:etherealness_83893", "Label": "Etherealness", "Name": "83893", "SpellDesc": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
  "overrides": [
    {
      "id": 443,
      "key": "spells:greater_sanctuary"
    },
    {
      "id": 724,
      "key": "spells:etherealness"
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
	if err != nil {
		t.Fatalf("discoverNativeDatasets failed: %v", err)
	}
	if len(datasets) != 1 {
		t.Fatalf("expected one dataset, got %d", len(datasets))
	}
	collectedDataset, err := collectNativeDataset(datasets[0])
	if err != nil {
		t.Fatalf("collectNativeDataset failed: %v", err)
	}
	if collectedDataset.LockData["spells:greater_sanctuary"] != 443 {
		t.Fatalf("expected collected greater_sanctuary lock entry, got %#v", collectedDataset.LockData)
	}
	if collectedDataset.LockData["spells:etherealness"] != 724 {
		t.Fatalf("expected collected etherealness to be remapped to row 724, got lock=%#v rows=%#v", collectedDataset.LockData, collectedDataset.Rows)
	}
	if _, ok := collectedDataset.LockData["spells:etherealness_83893"]; ok {
		t.Fatalf("expected collected etherealness_83893 to be retired, got %#v", collectedDataset.LockData)
	}

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "spells", "lock.json"))
	if err != nil {
		t.Fatalf("read spells lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"spells:greater_sanctuary": 443`) {
		t.Fatalf("expected greater_sanctuary lock entry, got:\n%s", lockText)
	}
	if !strings.Contains(lockText, `"spells:etherealness": 724`) {
		t.Fatalf("expected etherealness to be remapped to row 724, got:\n%s", lockText)
	}
	if strings.Contains(lockText, `"spells:etherealness_83893"`) {
		t.Fatalf("expected etherealness_83893 to be retired, got:\n%s", lockText)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
	if err != nil {
		t.Fatalf("read spells.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "GreaterSanctuary") || !strings.Contains(text, "Etherealness") {
		t.Fatalf("expected both renamed rows to remain present, got:\n%s", text)
	}
}

func TestBuildBaseitemsSplitReaddsRetiredKeyToLockfile(t *testing.T) {
	root := testProjectRoot(t)
	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", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["label", "MaxRange"],
  "rows": [
    {"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "00_ovr_baseitems_spear.json"), `{
  "overrides": [
    {
      "id": 58,
      "key": "baseitems:spear",
      "label": "spear"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_add_baseitems_shortspear.json"), `{
  "entries": {
    "baseitems:shortspear": {
      "label": "shortspear",
      "MaxRange": "255"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "20_ovr_baseitems_maxranges.json"), `{
  "overrides": [
    {
      "key": "baseitems:shortspear",
      "MaxRange": "255"
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "lock.json"))
	if err != nil {
		t.Fatalf("read baseitems lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"baseitems:spear": 58`) {
		t.Fatalf("expected spear lock entry, got:\n%s", lockText)
	}
	if !strings.Contains(lockText, `"baseitems:shortspear": 59`) {
		t.Fatalf("expected shortspear lock entry on new row, got:\n%s", lockText)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read baseitems.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "58\tspear") || !strings.Contains(text, "59\tshortspear") {
		t.Fatalf("expected split spear rows in output, got:\n%s", text)
	}
}

func TestBuildBaseitemsAllowsKeyOnlyOverrideBeforeSameKeyIsRelocatedAndReadded(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["label", "MaxRange"],
  "rows": [
    {"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:shortspear":59}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_maxranges.json"), `{
  "overrides": [
    {
      "key": "baseitems:shortspear",
      "MaxRange": "255"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_spear.json"), `{
  "overrides": [
    {
      "id": 58,
      "key": "baseitems:spear",
      "label": "spear"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "add_baseitems_shortspear.json"), `{
  "entries": {
    "baseitems:shortspear": {
      "label": "shortspear",
      "MaxRange": "255"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "lock.json"))
	if err != nil {
		t.Fatalf("read baseitems lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"baseitems:spear": 58`) {
		t.Fatalf("expected spear lock entry, got:\n%s", lockText)
	}
	lockData := map[string]int{}
	if err := json.Unmarshal(lockRaw, &lockData); err != nil {
		t.Fatalf("parse baseitems lock: %v", err)
	}
	shortspearID, ok := lockData["baseitems:shortspear"]
	if !ok {
		t.Fatalf("expected relocated shortspear lock entry to survive, got:\n%s", lockText)
	}
	if shortspearID == 58 {
		t.Fatalf("expected relocated shortspear to use a fresh row id, got lock:\n%s", lockText)
	}

	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
	if err != nil {
		t.Fatalf("read baseitems.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "58\tspear") || !strings.Contains(text, fmt.Sprintf("%d\tshortspear", shortspearID)) {
		t.Fatalf("expected split spear rows in output, got:\n%s", text)
	}
}

func TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds(t *testing.T) {
	root := testProjectRoot(t)
	writeFeatGeneratedHarness(t, root, map[string]string{
		"skill_focus.json": `{
  "family": "skill_focus",
  "family_key": "skill_focus",
  "template": "masterfeats:skillfocus",
  "name_prefix": "Skill Focus",
  "label_prefix": "FEAT_SKILL_FOCUS",
  "constant_prefix": "FEAT_SKILL_FOCUS",
  "default_fields": {
    "CRValue": "0.5",
    "ReqSkillMinRanks": "1",
    "ALLCLASSESCANUSE": "1",
    "TOOLSCATEGORIES": "6",
    "PreReqEpic": "0",
    "ReqAction": "1"
  },
  "apply_after_modules": true,
  "child_ref_field": "REQSKILL",
  "child_source": {
    "dataset": "skills",
    "predicate": "accessible"
  }
}` + "\n",
	}, map[string]int{
		"feat:skill_focus_intimidate": 1333,
	})
	mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides"))
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["label", "MaxRange"],
  "rows": [
    {"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{
  "baseitems:shortspear": 126
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_spear.json"), `{
  "overrides": [
    {
      "id": 58,
      "key": "baseitems:spear",
      "label": "spear"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "add_baseitems_shortspear.json"), `{
  "entries": {
    "baseitems:shortspear": {
      "label": "shortspear",
      "MaxRange": "255"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "1", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
  "skills:intimidate": 18
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "compare_reference": false,
  "columns": [
    "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
    "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
    "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
    "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
    "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
  ],
  "rows": [
    {"id": 39, "key": "feat:stunning_fist", "LABEL": "FEAT_STUNNING_FIST", "MASTERFEAT": "****", "Constant": "FEAT_STUNNING_FIST"},
    {"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:skill_focus_intimidate": 1333,
  "feat:stunning_fist": 1116
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_intimidate.json"), `{
  "overrides": [
    {"id": 916, "null": true, "key": null}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_stunningfist.json"), `{
  "overrides": [
    {"id": 39, "null": true, "key": null}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "add_feat_stunning_fist.json"), `{
  "entries": {
    "feat:stunning_fist": {
      "LABEL": "FEAT_STUNNING_FIST",
      "Constant": "FEAT_STUNNING_FIST"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	expectedBaseitems := map[string]int{
		"baseitems:spear":      58,
		"baseitems:shortspear": 126,
	}
	expectedFeat := map[string]int{
		"feat:skill_focus_intimidate": 1333,
		"feat:stunning_fist":          1116,
	}
	for run := 1; run <= 10; run++ {
		if _, err := BuildNative(testProject(root), nil); err != nil {
			t.Fatalf("BuildNative run %d failed: %v", run, err)
		}
		assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), expectedBaseitems)
		assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "feat", "lock.json"), expectedFeat)
	}
}

func assertLockIDs(t *testing.T, run int, path string, expected map[string]int) {
	t.Helper()
	raw, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("run %d: read lockfile %s: %v", run, path, err)
	}
	lockData := map[string]int{}
	if err := json.Unmarshal(raw, &lockData); err != nil {
		t.Fatalf("run %d: parse lockfile %s: %v\n%s", run, path, err, string(raw))
	}
	for key, want := range expected {
		if got := lockData[key]; got != want {
			t.Fatalf("run %d: expected %s to stay locked to id %d, got %d in %s:\n%s", run, key, want, got, path, string(raw))
		}
	}
}

func TestBuildSkillsIgnoresStaleBaseSpanLockDuringInitialLoad(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
  "output": "skills.2da",
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "342", "Description": "366", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "1", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"},
    {"id": 24, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_X2Inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
  "skills:intimidate": 18,
  "skills:taunt": 18
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
  "overrides": [
    {
      "id": 18,
      "key": "skills:intimidate",
      "Label": "Intimidate",
      "Constant": "SKILL_INTIMIDATE",
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
  "overrides": [
    {
      "id": 24,
      "key": null,
      "Label": null,
      "Constant": null,
      "HideFromLevelUp": "1"
    }
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	if _, err := buildNativeUnchecked(testProject(root), NativeBuildOptions{BuildWiki: false}, nil, nil); err != nil {
		t.Fatalf("buildNativeUnchecked failed: %v", err)
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "lock.json"))
	if err != nil {
		t.Fatalf("read skills lock: %v", err)
	}
	lockText := string(lockRaw)
	if !strings.Contains(lockText, `"skills:intimidate": 18`) {
		t.Fatalf("expected intimidate to remain locked to row 18, got:\n%s", lockText)
	}
	if strings.Contains(lockText, `"skills:taunt"`) {
		t.Fatalf("expected stale taunt alias to be pruned, got:\n%s", lockText)
	}
}

func TestBuildCanonicalSkillsMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "skills"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{
  "skills:athletics.name": 1000,
  "skills:athletics.description": 1001,
  "skills:craft_armorsmithing.name": 1002,
  "skills:craft_armorsmithing.description": 1003,
  "skills:knowledge_arcana.name": 1004
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "skills", "skills.json"), `{
  "entries": {
    "skills:athletics.name": {"text": "Athletics"},
    "skills:athletics.description": {"text": "Athletics description"},
    "skills:craft_armorsmithing.name": {"text": "Craft Armor Smithing"},
    "skills:craft_armorsmithing.description": {"text": "Craft armor description"},
    "skills:knowledge_arcana.name": {"text": "Knowledge Arcana"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
cols = ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"]
with open(os.path.join(out, "skills.2da"), "w", encoding="utf-8") as f:
    def emit(row_id, values):
        row = {c: "****" for c in cols}
        row.update(values)
        f.write(str(row_id) + "\t" + "\t".join(row[c] for c in cols) + "\n")
    f.write("2DA V2.0\n\n")
    f.write("\t".join(cols) + "\n")
    emit(0, {"Label": "AnimalEmpathy", "Name": "269", "Description": "344", "Icon": "isk_aniemp", "Untrained": "0", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "1", "Constant": "SKILL_ANIMAL_EMPATHY", "HostileSkill": "1", "HideFromLevelUp": "0"})
    emit(1, {"Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"})
    emit(2, {})
    emit(3, {})
    for row_id in range(4, 25):
        emit(row_id, {})
    emit(25, {"Label": "Craftarmorsmithing", "Name": "1002", "Description": "1003", "Icon": "isk_x2carm", "Untrained": "0", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CRAFT_ARMORSMITHING", "HostileSkill": "0", "HideFromLevelUp": "0"})
    emit(26, {})
    emit(27, {})
    emit(28, {"Label": "Athletics", "Name": "1000", "Description": "1001", "Icon": "isk_athletics", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_ATHLETICS", "HostileSkill": "0", "HideFromLevelUp": "0"})
    for row_id in range(29, 40):
        emit(row_id, {})
    emit(40, {"Label": "Knowledgearcana", "Name": "1004", "Description": "****", "Icon": "isk_knarcana", "Untrained": "1", "KeyAbility": "INT", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_KNOWLEDGE_ARCANA", "HostileSkill": "0", "HideFromLevelUp": "0"})
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "base.json"), `{
  "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
  "rows": [
    {"key": "skills:animalempathy", "id": 0, "Label": "AnimalEmpathy", "Name": "269", "Description": "344", "Icon": "isk_aniemp", "Untrained": "0", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "1", "Constant": "SKILL_ANIMAL_EMPATHY", "HostileSkill": "1", "HideFromLevelUp": "0"},
    {"key": "skills:concentration", "id": 1, "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:discipline", "id": 3, "Label": "Discipline", "Name": "343", "Description": "347", "Icon": "isk_discipline", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_DISCIPLINE", "HostileSkill": "0", "HideFromLevelUp": "0"},
    {"key": "skills:craft_armorsmithing", "id": 25, "Label": "Tumble", "Name": "280", "Description": "356", "Icon": "isk_tumble", "Untrained": "1", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TUMBLE", "HostileSkill": "0", "HideFromLevelUp": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "lock.json"), `{
  "skills:craft_armorsmithing": 25,
  "skills:athletics": 28,
  "skills:knowledge_arcana": 40
}`+"\n")
	for _, name := range []string{
		"add_disguise.json",
		"add_linguistics.json",
		"add_perception.json",
		"add_scriptcraft.json",
		"add_sensemotive.json",
		"add_stealth.json",
		"add_survival.json",
		"add_userope.json",
		"ovr_acrobatics.json",
		"ovr_animalhandling.json",
		"ovr_appraise.json",
		"ovr_concentration.json",
		"ovr_disabledevice.json",
		"ovr_heal.json",
		"ovr_hiddenskills.json",
		"ovr_influence.json",
		"ovr_openlock.json",
		"ovr_parry.json",
		"ovr_searchtowis.json",
		"ovr_sleightofhand.json",
		"ovr_taunttointimidate.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", name), `{"overrides": []}`+"\n")
	}
	for _, name := range []string{
		"add_craftalchemy.json",
		"add_craftcooking.json",
		"add_craftjewelry.json",
		"add_craftleatherworking.json",
		"add_craftstonework.json",
		"add_crafttextiles.json",
		"ovr_crafttinkering.json",
		"ovr_craftweaponsmithing.json",
		"ovr_craftwoodworking.json",
	} {
		payload := `{"entries": {}}`
		if strings.HasPrefix(name, "ovr_") {
			payload = `{"overrides": []}`
		}
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft", name), payload+"\n")
	}
	for _, name := range []string{
		"add_knowledgearchitecture.json",
		"add_knowledgedungeoneering.json",
		"add_knowledgegeography.json",
		"add_knowledgehistory.json",
		"add_knowledgelocal.json",
		"add_knowledgenature.json",
		"add_knowledgenobility.json",
		"add_knowledgeplanar.json",
		"add_knowledgereligion.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "add_athletics.json"), `{
  "entries": {
    "skills:athletics": {
      "Label": "Athletics",
      "Name": {"tlk": "skills:athletics.name"},
      "Description": {"tlk": "skills:athletics.description"},
      "Icon": "isk_athletics",
      "Untrained": "1",
      "KeyAbility": "STR",
      "ArmorCheckPenalty": "1",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_ATHLETICS",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"), `{
  "overrides": [
    {
      "key": "skills:craft_armorsmithing",
      "id": 25,
      "Label": "Craftarmorsmithing",
      "Name": {"tlk": "skills:craft_armorsmithing.name"},
      "Description": {"tlk": "skills:craft_armorsmithing.description"},
      "Icon": "isk_x2carm",
      "Untrained": "0",
      "Constant": "SKILL_CRAFT_ARMORSMITHING",
      "meta": {"wiki": {"status": "new"}},
      "HideFromLevelUp": "0"
    }
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "knowledge", "add_knowledgearcana.json"), `{
  "entries": {
    "skills:knowledge_arcana": {
      "Label": "Knowledgearcana",
      "Name": {"tlk": "skills:knowledge_arcana.name"},
      "Icon": "isk_knarcana",
      "Untrained": "1",
      "KeyAbility": "INT",
      "ArmorCheckPenalty": "0",
      "AllClassesCanUse": "1",
      "Constant": "SKILL_KNOWLEDGE_ARCANA",
      "HostileSkill": "0",
      "HideFromLevelUp": "0"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "skills", "modules", "rmv_removedskills.json"), `{
  "overrides": [
    {
      "key": null,
      "id": 3,
      "Label": "Discipline_REMOVED",
      "Constant": "****",
      "AllClassesCanUse": "0",
      "HideFromLevelUp": "1"
    }
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "skills.2da"))
	if err != nil {
		t.Fatalf("read native skills.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "skills.2da"))
	if err != nil {
		t.Fatalf("read reference skills.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("skills output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsLegacySpells(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "spells"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{
  "spells:specialattacks.name": 525,
  "feat:bardfascinate.name": 700,
  "feat:bardfascinate.description": 701,
  "feat:bardfascinate.alt": 702
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "spells", "specialattacks.json"), `{
  "entries": {
    "spells:specialattacks.name": {"text": "Special Attacks"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "bardfascinate.json"), `{
  "entries": {
    "feat:bardfascinate.name": {"text": "Fascinate"},
    "feat:bardfascinate.description": {"text": "Fascinate description"},
    "feat:bardfascinate.alt": {"text": "Alt message"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "base.json"), `{
  "columns": ["Label", "Name", "IconResRef", "School", "Range", "VS", "MetaMagic", "TargetType", "ImpactScript", "Bard", "Cleric", "Druid", "Paladin", "Ranger", "Wiz_Sorc", "Innate", "ConjTime", "ConjAnim", "ConjHeadVisual", "ConjHandVisual", "ConjGrndVisual", "ConjSoundVFX", "ConjSoundMale", "ConjSoundFemale", "CastAnim", "CastTime", "CastHeadVisual", "CastHandVisual", "CastGrndVisual", "CastSound", "Proj", "ProjModel", "ProjType", "ProjSpwnPoint", "ProjSound", "ProjOrientation", "ImmunityType", "ItemImmunity", "SubRadSpell1", "SubRadSpell2", "SubRadSpell3", "SubRadSpell4", "SubRadSpell5", "Category", "Master", "UserType", "SpellDesc", "UseConcentration", "SpontaneouslyCast", "AltMessage", "HostileSetting", "FeatID", "Counter1", "Counter2", "HasProjectile", "TargetShape", "TargetSizeX", "TargetSizeY", "TargetFlags", "SubRadSpell6", "SubRadSpell7", "SubRadSpell8"],
  "rows": [
    {"key": "spells:blessweapon", "id": 7, "Label": "Bless_Weapon", "Name": "123", "Category": "8"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "lock.json"), `{
  "spells:specialattacks": 840,
  "spells:disarm": 841,
  "spells:bardfascinate": 849
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "specialattacks.json"), `{
  "entries": {
    "spells:specialattacks": {
      "Label": "SpecialAttacks",
      "Name": {"tlk": "spells:specialattacks.name"},
      "Category": "22",
      "FeatID": {"id": "feat:specialattacks"},
      "SubRadSpell1": {"id": "spells:disarm"}
    },
    "spells:disarm": {
      "Label": "Disarm",
      "Name": {"tlk": "spells:disarm.name"},
      "FeatID": {"id": "feat:disarm"},
      "Master": {"id": "spells:specialattacks"},
      "Category": {"ref": "spells:specialattacks", "field": "Category"}
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
  "overrides": [
    {"key": null, "id": 7, "Label": "Bless_Weapon_OLD"}
  ]
}`+"\n")
	for _, name := range []string{
		"inspirecompetence.json",
		"inspirecourage.json",
		"inspiregreatness.json",
		"inspireheroics.json",
		"songoffreedom.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic", "fascinate.json"), `{
  "entries": {
    "spells:bardfascinate": {
      "Label": "Bard_Fascinate",
      "Name": {"tlk": "feat:bardfascinate.name"},
      "SpellDesc": {"tlk": "feat:bardfascinate.description"},
      "FeatID": {"id": "feat:bardfascinate"},
      "AltMessage": {"tlk": "feat:bardfascinate.alt"},
      "Category": "2"
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := NormalizeProject(testProject(root))
	if err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	if result.UpdatedFiles < 10 {
		t.Fatalf("expected spells files to be imported, got %#v", result)
	}
	for _, path := range []string{
		filepath.Join(root, "topdata", "data", "spells", "base.json"),
		filepath.Join(root, "topdata", "data", "spells", "lock.json"),
		filepath.Join(root, "topdata", "data", "spells", "modules", "specialattacks.json"),
		filepath.Join(root, "topdata", "data", "spells", "modules", "ovr_fixduplicates.json"),
		filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", "fascinate.json"),
	} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("expected imported spells path %s: %v", path, err)
		}
	}
	baseRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "spells", "base.json"))
	if err != nil {
		t.Fatalf("read spells base: %v", err)
	}
	if !strings.Contains(string(baseRaw), `"output": "spells.2da"`) {
		t.Fatalf("expected imported spells output name, got:\n%s", string(baseRaw))
	}
	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "spells", "lock.json"))
	if err != nil {
		t.Fatalf("read spells lock: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"spells:bardfascinate": 849`) || !strings.Contains(string(lockRaw), `"spells:specialattacks": 840`) {
		t.Fatalf("expected imported spells lock ids, got:\n%s", string(lockRaw))
	}
	moduleRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", "fascinate.json"))
	if err != nil {
		t.Fatalf("read spells bardicmusic module: %v", err)
	}
	if !strings.Contains(string(moduleRaw), `"text": "Fascinate"`) || !strings.Contains(string(moduleRaw), `"text": "Alt message"`) {
		t.Fatalf("expected normalized inline TLK payloads in bardicmusic module, got:\n%s", string(moduleRaw))
	}
}

func TestBuildSupportsCanonicalSpells(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic"))
	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": 1000, "key": "feat:specialattacks", "LABEL": "Special Attacks"},
    {"id": 1001, "key": "feat:disarm", "LABEL": "Disarm"},
    {"id": 1002, "key": "feat:bardfascinate", "LABEL": "Fascinate"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:specialattacks":1000,"feat:disarm":1001,"feat:bardfascinate":1002}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
  "output": "spells.2da",
  "columns": ["Label", "Name", "IconResRef", "School", "Range", "VS", "MetaMagic", "TargetType", "ImpactScript", "Bard", "Cleric", "Druid", "Paladin", "Ranger", "Wiz_Sorc", "Innate", "ConjTime", "ConjAnim", "ConjHeadVisual", "ConjHandVisual", "ConjGrndVisual", "ConjSoundVFX", "ConjSoundMale", "ConjSoundFemale", "CastAnim", "CastTime", "CastHeadVisual", "CastHandVisual", "CastGrndVisual", "CastSound", "Proj", "ProjModel", "ProjType", "ProjSpwnPoint", "ProjSound", "ProjOrientation", "ImmunityType", "ItemImmunity", "SubRadSpell1", "SubRadSpell2", "SubRadSpell3", "SubRadSpell4", "SubRadSpell5", "Category", "Master", "UserType", "SpellDesc", "UseConcentration", "SpontaneouslyCast", "AltMessage", "HostileSetting", "FeatID", "Counter1", "Counter2", "HasProjectile", "TargetShape", "TargetSizeX", "TargetSizeY", "TargetFlags", "SubRadSpell6", "SubRadSpell7", "SubRadSpell8"],
  "rows": [
    {"key": "spells:blessweapon", "id": 7, "Label": "Bless_Weapon", "Name": "123", "Category": "8"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
  "spells:specialattacks": 840,
  "spells:disarm": 841,
  "spells:bardfascinate": 849
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "specialattacks.json"), `{
  "entries": {
    "spells:specialattacks": {
      "Label": "SpecialAttacks",
      "Name": "500",
      "Category": "22",
      "FeatID": {"id": "feat:specialattacks"},
      "SubRadSpell1": {"id": "spells:disarm"}
    },
    "spells:disarm": {
      "Label": "Disarm",
      "Name": "501",
      "FeatID": {"id": "feat:disarm"},
      "Master": {"id": "spells:specialattacks"},
      "Category": {"ref": "spells:specialattacks", "field": "Category"}
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
  "overrides": [
    {"key": null, "id": 7, "Label": "Bless_Weapon_OLD"}
  ]
}`+"\n")
	for _, name := range []string{
		"inspirecompetence.json",
		"inspirecourage.json",
		"inspiregreatness.json",
		"inspireheroics.json",
		"songoffreedom.json",
	} {
		writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", "fascinate.json"), `{
  "entries": {
    "spells:bardfascinate": {
      "Label": "Bard_Fascinate",
      "Name": "600",
      "SpellDesc": "601",
      "FeatID": {"id": "feat:bardfascinate"},
      "AltMessage": "602",
      "Category": "2"
    }
  }
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
	if err != nil {
		t.Fatalf("read spells.2da: %v", err)
	}
	text := string(got)
	if !strings.Contains(text, "Label\tName\tIconResRef") ||
		!strings.Contains(text, "7\t****\t****\t****") ||
		!strings.Contains(text, "840\tSpecialAttacks\t500") ||
		!strings.Contains(text, "\t841\t") ||
		!strings.Contains(text, "\t22\t") ||
		!strings.Contains(text, "\t1000\t") ||
		!strings.Contains(text, "841\tDisarm\t501") ||
		!strings.Contains(text, "\t840\t") ||
		!strings.Contains(text, "\t1001\t") ||
		!strings.Contains(text, "849\tBard_Fascinate\t600") ||
		!strings.Contains(text, "\t601\t") ||
		!strings.Contains(text, "\t602\t") ||
		!strings.Contains(text, "\t1002\t") {
		t.Fatalf("unexpected spells.2da output:\n%s", text)
	}
}

func TestBuildCanonicalSpellsMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "spells"))
	mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "feat"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "lock.json"), `{
  "spells:specialattacks.name": 525,
  "spells:disarm.name": 526,
  "feat:bardfascinate.name": 700,
  "feat:bardfascinate.description": 701,
  "feat:bardfascinate.alt": 702
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "spells", "specialattacks.json"), `{
  "entries": {
    "spells:specialattacks.name": {"text": "Special Attacks"},
    "spells:disarm.name": {"text": "Disarm"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "bardfascinate.json"), `{
  "entries": {
    "feat:bardfascinate.name": {"text": "Fascinate"},
    "feat:bardfascinate.description": {"text": "Fascinate description"},
    "feat:bardfascinate.alt": {"text": "Alt message"}
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "spells.2da"), "w", encoding="utf-8") as f:
    cols = ["Label", "Name", "IconResRef", "School", "Range", "VS", "MetaMagic", "TargetType", "ImpactScript", "Bard", "Cleric", "Druid", "Paladin", "Ranger", "Wiz_Sorc", "Innate", "ConjTime", "ConjAnim", "ConjHeadVisual", "ConjHandVisual", "ConjGrndVisual", "ConjSoundVFX", "ConjSoundMale", "ConjSoundFemale", "CastAnim", "CastTime", "CastHeadVisual", "CastHandVisual", "CastGrndVisual", "CastSound", "Proj", "ProjModel", "ProjType", "ProjSpwnPoint", "ProjSound", "ProjOrientation", "ImmunityType", "ItemImmunity", "SubRadSpell1", "SubRadSpell2", "SubRadSpell3", "SubRadSpell4", "SubRadSpell5", "Category", "Master", "UserType", "SpellDesc", "UseConcentration", "SpontaneouslyCast", "AltMessage", "HostileSetting", "FeatID", "Counter1", "Counter2", "HasProjectile", "TargetShape", "TargetSizeX", "TargetSizeY", "TargetFlags", "SubRadSpell6", "SubRadSpell7", "SubRadSpell8"]
    def emit(row_id, values):
        row = {c: "****" for c in cols}
        row.update(values)
        f.write(str(row_id) + "\t" + "\t".join(row[c] for c in cols) + "\n")
    f.write("2DA V2.0\n\n")
    f.write("\t".join(cols) + "\n")
    for row_id in range(0, 7):
        emit(row_id, {})
    emit(7, {})
    for row_id in range(8, 840):
        emit(row_id, {})
    emit(840, {"Label": "SpecialAttacks", "Name": "16777741", "SubRadSpell1": "841", "Category": "22", "FeatID": "1000"})
    emit(841, {"Label": "Disarm", "Name": "16777742", "Category": "22", "Master": "840", "FeatID": "1001"})
    for row_id in range(842, 849):
        emit(row_id, {})
    emit(849, {"Label": "Bard_Fascinate", "Name": "16777916", "Category": "2", "SpellDesc": "16777917", "AltMessage": "16777918", "FeatID": "1002"})
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 1000, "key": "feat:specialattacks", "LABEL": "Special Attacks"},
    {"id": 1001, "key": "feat:disarm", "LABEL": "Disarm"},
    {"id": 1002, "key": "feat:bardfascinate", "LABEL": "Fascinate"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "feat", "lock.json"), `{"feat:specialattacks":1000,"feat:disarm":1001,"feat:bardfascinate":1002}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "base.json"), `{
  "columns": ["Label", "Name", "IconResRef", "School", "Range", "VS", "MetaMagic", "TargetType", "ImpactScript", "Bard", "Cleric", "Druid", "Paladin", "Ranger", "Wiz_Sorc", "Innate", "ConjTime", "ConjAnim", "ConjHeadVisual", "ConjHandVisual", "ConjGrndVisual", "ConjSoundVFX", "ConjSoundMale", "ConjSoundFemale", "CastAnim", "CastTime", "CastHeadVisual", "CastHandVisual", "CastGrndVisual", "CastSound", "Proj", "ProjModel", "ProjType", "ProjSpwnPoint", "ProjSound", "ProjOrientation", "ImmunityType", "ItemImmunity", "SubRadSpell1", "SubRadSpell2", "SubRadSpell3", "SubRadSpell4", "SubRadSpell5", "Category", "Master", "UserType", "SpellDesc", "UseConcentration", "SpontaneouslyCast", "AltMessage", "HostileSetting", "FeatID", "Counter1", "Counter2", "HasProjectile", "TargetShape", "TargetSizeX", "TargetSizeY", "TargetFlags", "SubRadSpell6", "SubRadSpell7", "SubRadSpell8"],
  "rows": [
    {"key": "spells:blessweapon", "id": 7, "Label": "Bless_Weapon", "Name": "123", "Category": "8"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "lock.json"), `{
  "spells:specialattacks": 840,
  "spells:disarm": 841,
  "spells:bardfascinate": 849
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "specialattacks.json"), `{
  "entries": {
    "spells:specialattacks": {
      "Label": "SpecialAttacks",
      "Name": {"tlk": "spells:specialattacks.name"},
      "Category": "22",
      "UserType": "3",
      "FeatID": {"id": "feat:specialattacks"},
      "SubRadSpell1": {"id": "spells:disarm"}
    },
    "spells:disarm": {
      "Label": "Disarm",
      "Name": {"tlk": "spells:disarm.name"},
      "FeatID": {"id": "feat:disarm"},
      "Master": {"id": "spells:specialattacks"},
      "Category": {"ref": "spells:specialattacks", "field": "Category"},
      "UserType": {"ref": "spells:specialattacks", "field": "UserType"}
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
  "overrides": [
    {"key": null, "id": 7, "Label": "Bless_Weapon_OLD"}
  ]
}`+"\n")
	for _, name := range []string{
		"inspirecompetence.json",
		"inspirecourage.json",
		"inspiregreatness.json",
		"inspireheroics.json",
		"songoffreedom.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "spells", "modules", "bardicmusic", "fascinate.json"), `{
  "entries": {
    "spells:bardfascinate": {
      "Label": "Bard_Fascinate",
      "Name": {"tlk": "feat:bardfascinate.name"},
      "SpellDesc": {"tlk": "feat:bardfascinate.description"},
      "FeatID": {"id": "feat:bardfascinate"},
      "AltMessage": {"tlk": "feat:bardfascinate.alt"},
      "Category": "2"
    }
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "spells.2da"))
	if err != nil {
		t.Fatalf("read native spells.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "spells.2da"))
	if err != nil {
		t.Fatalf("read reference spells.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("spells output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestBuildSupportsCanonicalPlaceables(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
  "output": "placeables.2da",
  "columns": ["Label", "StrRef", "ModelName", "LightColor", "LightOffsetX", "LightOffsetY", "LightOffsetZ", "SoundAppType", "ShadowSize", "BodyBag", "LowGore", "Reflection", "Static"],
  "rows": [
    {"key": "placeables:armoire", "id": 0, "Label": "Armoire", "StrRef": "5645", "ModelName": "PLC_A01", "SoundAppType": "13", "ShadowSize": "1", "BodyBag": "0", "Static": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{
  "placeables:armoire": 0,
  "placeables:tmp_crappile01": 16500
}`+"\n")
	for _, name := range []string{
		"add_ccfeb2019plcs.json",
		"add_cepcarpets.json",
		"add_cepfloors.json",
		"add_cepnwn2library.json",
		"add_nwic.json",
		"add_ori.json",
		"add_sic11.json",
		"add_tapr.json",
		"add_tdfloors.json",
		"add_undecals.json",
		"add_witcher1.json",
		"add_witcher2.json",
	} {
		writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "add_avernostragarbage.json"), `{
  "entries": {
    "placeables:tmp_crappile01": {
      "Label": "Decoration: Pile 1 (Avernostra)",
      "ModelName": "tmp_crappile01",
      "Static": 1
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "ovr_vanillaplcsorts.json"), `{
  "overrides": [
    {"key": "placeables:armoire", "id": 0, "Label": "Furniture: Armoire (Bioware)", "StrRef": "****"}
  ]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	result, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	got, err := os.ReadFile(filepath.Join(result.Output2DADir, "placeables.2da"))
	if err != nil {
		t.Fatalf("read placeables.2da: %v", err)
	}
	if !strings.Contains(string(got), "Label\tStrRef\tModelName\tLightColor\tLightOffsetX\tLightOffsetY\tLightOffsetZ\tSoundAppType\tShadowSize\tBodyBag\tLowGore\tReflection\tStatic\n") ||
		!strings.Contains(string(got), "0\t\"Furniture: Armoire (Bioware)\"\t****\tPLC_A01") ||
		!strings.Contains(string(got), "\t13\t1\t0\t****\t****\t1\n") ||
		!strings.Contains(string(got), "16500\t\"Decoration: Pile 1 (Avernostra)\"\t****\ttmp_crappile01") {
		t.Fatalf("unexpected placeables.2da output:\n%s", string(got))
	}
}

func TestBuildCanonicalPlaceablesMatchesReference(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "placeables", "modules"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
import os, sys
out = None
tlk = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--out":
        out = args[i + 1]
    if arg == "--tlk-dir":
        tlk = args[i + 1]
os.makedirs(out, exist_ok=True)
os.makedirs(tlk, exist_ok=True)
with open(os.path.join(out, "placeables.2da"), "w", encoding="utf-8") as f:
    f.write("2DA V2.0\n\n")
    f.write("Label\tStrRef\tModelName\tLightColor\tLightOffsetX\tLightOffsetY\tLightOffsetZ\tSoundAppType\tShadowSize\tBodyBag\tLowGore\tReflection\tStatic\n")
    f.write("0\t\"Furniture: Armoire (Bioware)\"\t****\tPLC_A01\t****\t****\t****\t****\t13\t1\t0\t****\t****\t1\n")
    for row_id in range(1, 16500):
        f.write(f"{row_id}\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n")
    f.write("16500\t\"Decoration: Pile 1 (Avernostra)\"\t****\ttmp_crappile01\t****\t****\t****\t****\t****\t****\t****\t****\t1\n")
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
    f.write(b"TLK")
`)
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "base.json"), `{
  "columns": ["Label", "StrRef", "ModelName", "LightColor", "LightOffsetX", "LightOffsetY", "LightOffsetZ", "SoundAppType", "ShadowSize", "BodyBag", "LowGore", "Reflection", "Static"],
  "rows": [
    {"key": "placeables:armoire", "id": 0, "Label": "Armoire", "StrRef": "5645", "ModelName": "PLC_A01", "SoundAppType": "13", "ShadowSize": "1", "BodyBag": "0", "Static": "1"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "lock.json"), `{
  "placeables:armoire": 0,
  "placeables:tmp_crappile01": 16500
}`+"\n")
	for _, name := range []string{
		"add_ccfeb2019plcs.json",
		"add_cepcarpets.json",
		"add_cepfloors.json",
		"add_cepnwn2library.json",
		"add_nwic.json",
		"add_ori.json",
		"add_sic11.json",
		"add_tapr.json",
		"add_tdfloors.json",
		"add_undecals.json",
		"add_witcher1.json",
		"add_witcher2.json",
	} {
		writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", name), `{"entries": {}}`+"\n")
	}
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", "add_avernostragarbage.json"), `{
  "entries": {
    "placeables:tmp_crappile01": {
      "Label": "Decoration: Pile 1 (Avernostra)",
      "ModelName": "tmp_crappile01",
      "Static": 1
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "placeables", "modules", "ovr_vanillaplcsorts.json"), `{
  "overrides": [
    {"key": "placeables:armoire", "id": 0, "Label": "Furniture: Armoire (Bioware)", "StrRef": "****"}
  ]
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}
	nativeResult, err := BuildNative(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}
	referenceResult, err := BuildReference(testProject(root), nil)
	if err != nil {
		t.Fatalf("BuildReference failed: %v", err)
	}
	nativeBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, "placeables.2da"))
	if err != nil {
		t.Fatalf("read native placeables.2da: %v", err)
	}
	referenceBytes, err := os.ReadFile(filepath.Join(referenceResult.Output2DADir, "placeables.2da"))
	if err != nil {
		t.Fatalf("read reference placeables.2da: %v", err)
	}
	if string(nativeBytes) != string(referenceBytes) {
		t.Fatalf("placeables output mismatch\nnative:\n%s\nreference:\n%s", string(nativeBytes), string(referenceBytes))
	}
}

func TestNormalizeProjectImportsRacialtypesRegistry(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "racialtypes", "core", "modules"))
	mkdirAll(t, filepath.Join(root, "reference", "data", "racialtypes", "feats"))
	mkdirAll(t, filepath.Join(root, "reference", "tlk", "modules", "racialtypes"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
	writeFile(t, filepath.Join(root, "reference", "data", "racialtypes", "core", "base.json"), `{
  "output": "racialtypes.2da",
  "columns": ["Label", "Name", "Appearance", "ToolsetDefaultClass", "FeatsTable", "Description"],
  "rows": [
    {"id": 6, "Label": "Human", "Name": "34", "Appearance": "0", "ToolsetDefaultClass": "4", "FeatsTable": "RACE_FEAT_HUMAN", "Description": "****"},
    {"id": 7, "Label": "Beast", "Name": "527", "Appearance": "****", "ToolsetDefaultClass": "****", "FeatsTable": "****", "Description": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "racialtypes", "core", "lock.json"), `{
  "racialtypes:human": 6,
  "racialtypes:tiefling": 31
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "racialtypes", "core", "modules", "ovr_human.json"), `{
  "overrides": [
    {"key": "racialtypes:human", "id": 6, "Label": "Human", "Description": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "racialtypes", "core", "modules", "tiefling.json"), `{
  "entries": {
    "racialtypes:tiefling": {
      "Label": "Tiefling",
      "Appearance": {"id": "appearance:human"},
      "ToolsetDefaultClass": {"id": "classes:rogue"},
      "FeatsTable": {"table": "racialtypes/feats:tiefling"},
      "Description": {"tlk": "racialtypes:tiefling.description"}
    }
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "data", "racialtypes", "feats", "tiefling.json"), `{
  "key": "racialtypes/feats:tiefling",
  "output": "race_feat_tief.2da",
  "columns": ["FeatLabel", "FeatIndex"],
  "rows": [
    {"FeatLabel": "Darkvision", "FeatIndex": {"id": "feat:darkvision"}}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "lock.json"), `{"racialtypes:tiefling.description": 100}`+"\n")
	writeFile(t, filepath.Join(root, "reference", "tlk", "modules", "racialtypes", "tiefling.json"), `{
  "entries": {
    "racialtypes:tiefling": {
      "description": {"text": "Tiefling description"}
    }
  }
}`+"\n")

	if _, err := NormalizeProject(testProject(root)); err != nil {
		t.Fatalf("NormalizeProject failed: %v", err)
	}

	baseRowsRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "racialtypes", "registry", "base.json"))
	if err != nil {
		t.Fatalf("read base.json: %v", err)
	}
	baseText := string(baseRowsRaw)
	if !strings.Contains(baseText, `"Label": "Beast"`) || !strings.Contains(baseText, `"key": "racialtypes:beast"`) {
		t.Fatalf("expected keyed static core row scaffold, got:\n%s", baseText)
	}

	lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "racialtypes", "registry", "lock.json"))
	if err != nil {
		t.Fatalf("read lock.json: %v", err)
	}
	if !strings.Contains(string(lockRaw), `"racialtypes:human": 6`) || !strings.Contains(string(lockRaw), `"racialtypes:tiefling": 31`) || !strings.Contains(string(lockRaw), `"racialtypes:beast": 7`) {
		t.Fatalf("expected preserved lock ids, got:\n%s", string(lockRaw))
	}

	tieflingRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "tiefling.json"))
	if err != nil {
		t.Fatalf("read tiefling.json: %v", err)
	}
	tieflingText := string(tieflingRaw)
	if !strings.Contains(tieflingText, `"feat_output": "race_feat_tief.2da"`) ||
		!strings.Contains(tieflingText, `"text": "Tiefling description"`) ||
		!strings.Contains(tieflingText, `"FeatLabel": "Darkvision"`) {
		t.Fatalf("unexpected tiefling registry row:\n%s", tieflingText)
	}
}

func TestBuildSupportsRacialtypesRegistryWithSnapshotClassIDs(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races"))
	mkdirAll(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "core"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
  "output": "appearance.2da",
  "columns": ["LABEL"],
  "rows": [
    {"id": 0, "key": "appearance:human", "LABEL": "Human"},
    {"id": 1, "key": "appearance:elf", "LABEL": "Elf"},
    {"id": 2, "key": "appearance:halfogre", "LABEL": "Half-Ogre"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{
  "appearance:human": 0,
  "appearance:elf": 1,
  "appearance:halfogre": 2
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
  "output": "feat.2da",
  "columns": ["LABEL"],
  "rows": []
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:darkvision": 77
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "core", "lock.json"), `{
  "classes:fighter": 4,
  "classes:rogue": 8
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "base.json"), `{
  "columns": ["Label", "Appearance", "ToolsetDefaultClass", "FeatsTable"],
  "rows": [
    {"id": 7, "key": "racialtypes:beast", "Label": "Beast", "Appearance": "****", "ToolsetDefaultClass": "****", "FeatsTable": "****"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "lock.json"), `{
  "racialtypes:beast": 7,
  "racialtypes:human": 6,
  "racialtypes:tiefling": 31
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "human.json"), `{
  "key": "racialtypes:human",
  "core": {
    "Label": "Human",
    "Appearance": {"id": "appearance:human"},
    "ToolsetDefaultClass": {"id": "classes:fighter"},
    "FeatsTable": "RACE_FEAT_HUMAN"
  }
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "tiefling.json"), `{
  "key": "racialtypes:tiefling",
  "core": {
    "Label": "Tiefling",
    "Appearance": {"id": "appearance:human"},
    "ToolsetDefaultClass": {"id": "classes:rogue"}
  },
  "feat_output": "race_feat_tief.2da",
  "feats": [
    {"FeatLabel": "Darkvision", "FeatIndex": {"id": "feat:darkvision"}}
  ]
}`+"\n")

	p := testProject(root)
	p.Config.TopData.ReferenceBuilder = ""
	result, err := BuildNative(p, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	coreBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
	if err != nil {
		t.Fatalf("read racialtypes.2da: %v", err)
	}
	coreText := string(coreBytes)
	if !strings.Contains(coreText, "Label\tAppearance\tToolsetDefaultClass\tFeatsTable\n") ||
		!strings.Contains(coreText, "6\tHuman\t0\t4\tRACE_FEAT_HUMAN\n") ||
		!strings.Contains(coreText, "31\tTiefling\t0\t8\trace_feat_tief\n") {
		t.Fatalf("unexpected racialtypes.2da output:\n%s", coreText)
	}

	featBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "race_feat_tief.2da"))
	if err != nil {
		t.Fatalf("read race_feat_tief.2da: %v", err)
	}
	if !strings.Contains(string(featBytes), "FeatLabel\tFeatIndex\n0\tDarkvision\t77\n") {
		t.Fatalf("unexpected race_feat_tief.2da output:\n%s", string(featBytes))
	}
}

func TestScanAutogeneratedParts(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
	mkdirAll(t, filepath.Join(root, "assets", "part", "chest"))
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt171.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt172.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "chest", "pfa0_chest081.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "chest", "pfa0_chest095.mdl"), "")

	result, err := scanAutogeneratedParts(filepath.Join(root, "assets"))
	if err != nil {
		t.Fatalf("scanAutogeneratedParts failed: %v", err)
	}

	beltIDs, ok := result["belt"]
	if !ok {
		t.Fatal("expected belt category in result")
	}
	if len(beltIDs) != 3 {
		t.Fatalf("expected 3 belt IDs, got %d", len(beltIDs))
	}
	for _, id := range []int{18, 171, 172} {
		if _, exists := beltIDs[id]; !exists {
			t.Errorf("expected belt ID %d to exist", id)
		}
	}

	chestIDs, ok := result["chest"]
	if !ok {
		t.Fatal("expected chest category in result")
	}
	if len(chestIDs) != 2 {
		t.Fatalf("expected 2 chest IDs, got %d", len(chestIDs))
	}
	for _, id := range []int{81, 95} {
		if _, exists := chestIDs[id]; !exists {
			t.Errorf("expected chest ID %d to exist", id)
		}
	}
}

func TestScanAutogeneratedPartsSkipsNonNumericSuffix(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_beltABC.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt_test.mdl"), "")

	result, err := scanAutogeneratedParts(root)
	if err != nil {
		t.Fatalf("scanAutogeneratedParts failed: %v", err)
	}

	beltIDs, ok := result["belt"]
	if !ok {
		t.Fatal("expected belt category in result")
	}
	if len(beltIDs) != 0 {
		t.Fatalf("expected 0 belt IDs (non-numeric suffixes), got %d", len(beltIDs))
	}
}

func TestScanAutogeneratedPartsReturnsNilForEmptyPath(t *testing.T) {
	result, err := scanAutogeneratedParts("")
	if err != nil {
		t.Fatalf("scanAutogeneratedParts failed: %v", err)
	}
	if result != nil {
		t.Fatalf("expected nil result for empty path, got %v", result)
	}
}

func TestAugmentWithAutogeneratedParts(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Name:       "parts/belt",
				OutputName: "parts_belt.2da",
			},
			Columns: []string{"COSTMODIFIER", "ACBONUS"},
			Rows: []map[string]any{
				{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"},
			},
			LockData: map[string]int{},
		},
	}

	autogenerated := map[string]map[int]struct{}{
		"belt":  {18: {}, 171: {}, 172: {}},
		"chest": {81: {}},
	}

	result := augmentWithAutogeneratedParts(collected, autogenerated)

	if len(result) != 1 {
		t.Fatalf("expected 1 dataset, got %d", len(result))
	}
	beltDataset := result[0]
	if len(beltDataset.Rows) != 4 {
		t.Fatalf("expected 4 rows (1 existing + 3 autogenerated), got %d", len(beltDataset.Rows))
	}

	rowByID := make(map[int]map[string]any)
	for _, row := range beltDataset.Rows {
		id := row["id"].(int)
		rowByID[id] = row
	}

	if cost, ok := rowByID[0]["COSTMODIFIER"].(string); !ok || cost != "1" {
		t.Errorf("expected existing row 0 to keep COSTMODIFIER=1, got %v", rowByID[0]["COSTMODIFIER"])
	}

	for _, id := range []int{18, 171, 172} {
		row := rowByID[id]
		if cost, ok := row["COSTMODIFIER"].(string); !ok || cost != "0" {
			t.Errorf("expected autogenerated row %d to have COSTMODIFIER=0, got %v", id, row["COSTMODIFIER"])
		}
		if ac, ok := row["ACBONUS"].(string); !ok || ac != "0.00" {
			t.Errorf("expected autogenerated row %d to have ACBONUS=0.00, got %v", id, row["ACBONUS"])
		}
	}
}

func TestAugmentWithAutogeneratedPartsRespectsExistingIDs(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Name:       "parts/belt",
				OutputName: "parts_belt.2da",
			},
			Columns: []string{"COSTMODIFIER", "ACBONUS"},
			Rows: []map[string]any{
				{"id": 18, "COSTMODIFIER": "5", "ACBONUS": "1.00"},
			},
			LockData: map[string]int{},
		},
	}

	autogenerated := map[string]map[int]struct{}{
		"belt": {18: {}, 171: {}},
	}

	result := augmentWithAutogeneratedParts(collected, autogenerated)

	beltDataset := result[0]
	if len(beltDataset.Rows) != 2 {
		t.Fatalf("expected 2 rows (1 existing with ID 18, 1 autogenerated with ID 171), got %d", len(beltDataset.Rows))
	}

	rowByID := make(map[int]map[string]any)
	for _, row := range beltDataset.Rows {
		id := row["id"].(int)
		rowByID[id] = row
	}

	if cost, ok := rowByID[18]["COSTMODIFIER"].(string); !ok || cost != "5" {
		t.Errorf("expected existing row 18 to keep COSTMODIFIER=5, got %v", rowByID[18]["COSTMODIFIER"])
	}

	if cost, ok := rowByID[171]["COSTMODIFIER"].(string); !ok || cost != "0" {
		t.Errorf("expected autogenerated row 171 to have COSTMODIFIER=0, got %v", rowByID[171]["COSTMODIFIER"])
	}
}

func TestNormalizePartsRowsACBonusUsesConfiguredPolicyBeforeOverrides(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{Name: "parts/belt", OutputName: "parts_belt.2da"},
			Columns: []string{"COSTMODIFIER", "ACBONUS"},
			Rows: []map[string]any{
				{"id": 1, "COSTMODIFIER": "5", "ACBONUS": "0.90"},
				{"id": 999, "COSTMODIFIER": "0", "ACBONUS": "****"},
			},
		},
		{
			Dataset: nativeDataset{Name: "parts/chest", OutputName: "parts_chest.2da"},
			Columns: []string{"COSTMODIFIER", "ACBONUS"},
			Rows: []map[string]any{
				{"id": 14, "COSTMODIFIER": "0", "ACBONUS": "8.00"},
			},
		},
		{
			Dataset: nativeDataset{Name: "parts/robe", OutputName: "parts_robe.2da"},
			Columns: []string{"COSTMODIFIER", "ACBONUS"},
			Rows: []map[string]any{
				{"id": 95, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
			},
		},
	}
	cfg := project.PartsRowsConfig{
		RowDefaults: map[string]string{"COSTMODIFIER": "0"},
		ACBonus: project.PartsRowsACBonusConfig{
			Default: project.PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"},
			Datasets: map[string]project.PartsRowsACBonusPolicy{
				"chest": {Strategy: "fixed", Value: "0.00"},
			},
		},
	}

	got, err := normalizePartsRowsACBonus(collected, cfg)
	if err != nil {
		t.Fatalf("normalizePartsRowsACBonus failed: %v", err)
	}

	beltRows := rowsByID(got[0].Rows)
	if got := beltRows[1]["ACBONUS"]; got != "9.98" {
		t.Fatalf("expected authored belt ACBONUS to be replaced with computed sort key 9.98, got %v", got)
	}
	if got := beltRows[999]["ACBONUS"]; got != "0.00" {
		t.Fatalf("expected highest belt row to sort at 0.00, got %v", got)
	}
	chestRows := rowsByID(got[1].Rows)
	if got := chestRows[14]["ACBONUS"]; got != "0.00" {
		t.Fatalf("expected chest ACBONUS to be forced to 0.00, got %v", got)
	}
	robeRows := rowsByID(got[2].Rows)
	if got := robeRows[95]["ACBONUS"]; got != "9.04" {
		t.Fatalf("expected robe to use computed non-chest sort key, got %v", got)
	}
}

func rowsByID(rows []map[string]any) map[int]map[string]any {
	out := map[int]map[string]any{}
	for _, row := range rows {
		id, _ := row["id"].(int)
		out[id] = row
	}
	return out
}

func TestAugmentWithAutogeneratedPartsSortsRows(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Name:       "parts/belt",
				OutputName: "parts_belt.2da",
			},
			Columns: []string{"COSTMODIFIER"},
			Rows: []map[string]any{
				{"id": 100, "COSTMODIFIER": "1"},
			},
			LockData: map[string]int{},
		},
	}

	autogenerated := map[string]map[int]struct{}{
		"belt": {18: {}, 171: {}},
	}

	result := augmentWithAutogeneratedParts(collected, autogenerated)

	beltDataset := result[0]
	if len(beltDataset.Rows) != 3 {
		t.Fatalf("expected 3 rows, got %d", len(beltDataset.Rows))
	}

	for i, row := range beltDataset.Rows {
		expectedID := []int{18, 100, 171}[i]
		if id := row["id"].(int); id != expectedID {
			t.Errorf("expected row at index %d to have ID %d, got %d", i, expectedID, id)
		}
	}
}

func TestAugmentWithAutogeneratedRobePartsDefaultsHideColumns(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Name:       "parts/robe",
				OutputName: "parts_robe.2da",
			},
			Columns: []string{
				"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
			},
			Rows:     []map[string]any{},
			LockData: map[string]int{},
		},
	}

	autogenerated := map[string]map[int]struct{}{
		"robe": {95: {}},
	}

	result := augmentWithAutogeneratedParts(collected, autogenerated)
	robeDataset := result[0]
	if len(robeDataset.Rows) != 1 {
		t.Fatalf("expected 1 autogenerated robe row, got %d", len(robeDataset.Rows))
	}

	row := robeDataset.Rows[0]
	if got := row["id"]; got != 95 {
		t.Fatalf("expected autogenerated robe row id 95, got %v", got)
	}
	if got := row["COSTMODIFIER"]; got != "0" {
		t.Fatalf("expected COSTMODIFIER=0, got %v", got)
	}
	if got := row["ACBONUS"]; got != "0.00" {
		t.Fatalf("expected ACBONUS=0.00, got %v", got)
	}
	for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
		if got := row[column]; got != "0" {
			t.Fatalf("expected %s=0, got %v", column, got)
		}
	}
}

func TestAugmentWithAutogeneratedRobePartsActivatesUnsetHideColumns(t *testing.T) {
	collected := []nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Name:       "parts/robe",
				OutputName: "parts_robe.2da",
			},
			Columns: []string{
				"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
			},
			Rows: []map[string]any{
				{
					"id":           95,
					"COSTMODIFIER": "****",
					"ACBONUS":      "****",
					"HIDEFOOTR":    "****",
					"HIDEFOOTL":    "****",
					"HIDECHEST":    "****",
				},
			},
			LockData: map[string]int{},
		},
	}

	autogenerated := map[string]map[int]struct{}{
		"robe": {95: {}},
	}

	result := augmentWithAutogeneratedParts(collected, autogenerated)
	row := result[0].Rows[0]
	if got := row["COSTMODIFIER"]; got != "0" {
		t.Fatalf("expected COSTMODIFIER=0, got %v", got)
	}
	if got := row["ACBONUS"]; got != "0.00" {
		t.Fatalf("expected ACBONUS=0.00, got %v", got)
	}
	for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
		if got := row[column]; got != "0" {
			t.Fatalf("expected %s=0, got %v", column, got)
		}
	}
}

func TestBuildNativeWithAutogeneratedParts(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"}]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "")
	writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt171.mdl"), "")

	proj := testProject(root)
	proj.Config.TopData.Assets = filepath.Join(root, "assets")

	result, err := BuildNative(proj, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
	if err != nil {
		t.Fatalf("read parts_belt.2da: %v", err)
	}
	partsText := string(partsBytes)

	if !strings.Contains(partsText, "0\t1\t0.25") {
		t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
	}
	if !strings.Contains(partsText, "18\t0\t0.00") {
		t.Errorf("expected autogenerated row 18, got:\n%s", partsText)
	}
	if !strings.Contains(partsText, "171\t0\t0.00") {
		t.Errorf("expected autogenerated row 171, got:\n%s", partsText)
	}
}

func TestBuildNativeWithAutogeneratedRobeParts(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "robe.json"), `{
  "output": "parts_robe.2da",
  "columns": ["COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"],
  "rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25", "HIDEFOOTR": "1", "HIDEFOOTL": "1", "HIDECHEST": "1"}]
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	mkdirAll(t, filepath.Join(root, "assets", "part", "robe"))
	writeFile(t, filepath.Join(root, "assets", "part", "robe", "pfa0_robe095.mdl"), "")

	proj := testProject(root)
	proj.Config.TopData.Assets = filepath.Join(root, "assets")

	result, err := BuildNative(proj, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_robe.2da"))
	if err != nil {
		t.Fatalf("read parts_robe.2da: %v", err)
	}
	partsText := string(partsBytes)

	if !strings.Contains(partsText, "95\t0\t0.00\t0\t0\t0") {
		t.Fatalf("expected autogenerated robe row 95 with zeroed hide defaults, got:\n%s", partsText)
	}
}

func TestBuildNativeWithReleasedPartsManifest(t *testing.T) {
	root := t.TempDir()
	projRoot := filepath.Join(root, "project")
	mkdirAll(t, filepath.Join(projRoot, "src"))
	mkdirAll(t, filepath.Join(projRoot, "assets"))
	mkdirAll(t, filepath.Join(projRoot, "build"))
	mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"}]
}`+"\n")
	mkdirAll(t, filepath.Join(projRoot, "reference"))
	writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")

	manifestJSON := `{
  "repo": "ShadowsOverWestgate/sow-assets",
  "ref": "test-manifest",
  "generated_at": "2026-04-09T00:00:00Z",
  "categories": {
    "belt": [18, 171]
  }
}` + "\n"
	var server *httptest.Server
	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/parts-manifest-current":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"assets":[{"name":"sow-parts-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-parts-manifest.json"}]}`))
		case "/downloads/sow-parts-manifest.json":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(manifestJSON))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", projRoot)
	runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")

	proj := &project.Project{
		Root: projRoot,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				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",
						},
					},
				},
			},
		},
	}

	result, err := BuildNative(proj, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
	if err != nil {
		t.Fatalf("read parts_belt.2da: %v", err)
	}
	partsText := string(partsBytes)

	if !strings.Contains(partsText, "0\t1\t0.25") {
		t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
	}
	if !strings.Contains(partsText, "18\t0\t0.00") {
		t.Errorf("expected autogenerated row 18 from released manifest, got:\n%s", partsText)
	}
	if !strings.Contains(partsText, "171\t0\t0.00") {
		t.Errorf("expected autogenerated row 171 from released manifest, got:\n%s", partsText)
	}
}

func TestBuildNativeWithReleasedPartsManifestUsesAssetsServerOverride(t *testing.T) {
	root := t.TempDir()
	projRoot := filepath.Join(root, "project")
	mkdirAll(t, filepath.Join(projRoot, "src"))
	mkdirAll(t, filepath.Join(projRoot, "assets"))
	mkdirAll(t, filepath.Join(projRoot, "build"))
	mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": []
}`+"\n")
	mkdirAll(t, filepath.Join(projRoot, "reference"))
	writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")

	manifestJSON := `{
  "repo": "ShadowsOverWestgate/sow-assets",
  "ref": "test-manifest",
  "generated_at": "2026-04-09T00:00:00Z",
  "categories": {
    "belt": [18]
  }
}` + "\n"
	var server *httptest.Server
	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/parts-manifest-current":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"assets":[{"name":"sow-parts-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-parts-manifest.json"}]}`))
		case "/downloads/sow-parts-manifest.json":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(manifestJSON))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", projRoot)
	runGitTest(t, projRoot, "remote", "add", "origin", "git@git-ssh.westgate.pw:ShadowsOverWestgate/sow-module.git")
	t.Setenv("SOW_ASSETS_SERVER_URL", server.URL)
	t.Setenv("SOW_ASSETS_REPO", "ShadowsOverWestgate/sow-assets")

	proj := &project.Project{
		Root: projRoot,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				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",
						},
					},
				},
			},
		},
	}

	result, err := BuildNative(proj, nil)
	if err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
	if err != nil {
		t.Fatalf("read parts_belt.2da: %v", err)
	}
	if partsText := string(partsBytes); !strings.Contains(partsText, "18\t0\t0.00") {
		t.Errorf("expected autogenerated row 18 from released manifest, got:\n%s", partsText)
	}
}

func TestBuildNativeWithReleasedHeadVisualeffectsManifest(t *testing.T) {
	root := t.TempDir()
	projRoot := filepath.Join(root, "project")
	mkdirAll(t, filepath.Join(projRoot, "src"))
	mkdirAll(t, filepath.Join(projRoot, "assets"))
	mkdirAll(t, filepath.Join(projRoot, "build"))
	mkdirAll(t, filepath.Join(projRoot, ".cache"))
	mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "visualeffects"))
	writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "base.json"), `{
  "output": "visualeffects.2da",
  "columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"],
  "rows": [
    {"key": "visualeffects:existing", "id": 17, "Label": "EXISTING", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "OrientWithObject": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "lock.json"), `{
  "visualeffects:existing": 17
}`+"\n")
	mkdirAll(t, filepath.Join(projRoot, "reference"))
	writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")

	manifestJSON := `{
  "id": "head_visualeffects",
  "repo": "ShadowsOverWestgate/sow-assets",
  "ref": "test-manifest",
  "generated_at": "2026-04-25T00:00:00Z",
  "entries": [
    {
      "group": "head_accessories",
      "model_stem": "hfx_bandana",
      "source": "head_accessories/hfx_bandana.mdl"
    },
    {
      "group": "head_features",
      "model_stem": "hfx_hair_bangs",
      "source": "head_features/hair/hfx_hair_bangs.mdl"
    }
  ]
}` + "\n"

	var server *httptest.Server
	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"assets":[{"name":"sow-head-vfx-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-head-vfx-manifest.json"}]}`))
		case "/downloads/sow-head-vfx-manifest.json":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(manifestJSON))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", projRoot)
	runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")

	proj := &project.Project{
		Root: projRoot,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				Source: "topdata",
				Build:  "build/topdata",
			},
			Autogen: project.AutogenConfig{
				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",
						},
					},
				},
			},
		},
	}

	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\theadaccessory_BANDANA\tD\t0\thfx_bandana\t1",
		"1\theadfeature_HAIR_BANGS\tD\t0\thfx_hair_bangs\t1",
		"17\tEXISTING\tF\t0\t****\t0",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected visualeffects output to contain %q, got:\n%s", want, text)
		}
	}
}

func TestBuildNativePrefersRemoteReleasedHeadVisualeffectsManifestOverFreshCache(t *testing.T) {
	root := t.TempDir()
	projRoot := filepath.Join(root, "project")
	mkdirAll(t, filepath.Join(projRoot, "src"))
	mkdirAll(t, filepath.Join(projRoot, "assets"))
	mkdirAll(t, filepath.Join(projRoot, "build"))
	mkdirAll(t, filepath.Join(projRoot, ".cache"))
	mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "visualeffects"))
	writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "base.json"), `{
  "output": "visualeffects.2da",
  "columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"],
  "rows": [
    {"key": "visualeffects:existing", "id": 17, "Label": "EXISTING", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "OrientWithObject": "0"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "lock.json"), `{
  "visualeffects:existing": 17,
  "visualeffects:headaccessory_bandana": 42
}`+"\n")
	mkdirAll(t, filepath.Join(projRoot, "reference"))
	writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")

	cachePath := filepath.Join(projRoot, ".cache", "sow-head-vfx-manifest.json")
	writeFile(t, cachePath, `{
  "id": "head_visualeffects",
  "generated_at": "2026-04-25T00:00:00Z",
  "entries": [
    {
      "group": "head_features",
      "model_stem": "hfx_hair_bangs",
      "source": "head_features/hair/hfx_hair_bangs.mdl"
    }
  ]
}`+"\n")
	cacheTime := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
	setFileTime(t, cachePath, cacheTime)

	manifestJSON := `{
  "id": "head_visualeffects",
  "repo": "ShadowsOverWestgate/sow-assets",
  "ref": "remote-manifest",
  "generated_at": "2026-04-25T12:00:00Z",
  "entries": [
    {
      "group": "head_accessories",
      "model_stem": "hfx_bandana",
      "source": "head_accessories/hfx_bandana.mdl"
    }
  ]
}` + "\n"

	var server *httptest.Server
	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"assets":[{"name":"sow-head-vfx-manifest.json","updated_at":"2026-04-25T12:00:00Z","browser_download_url":"` + server.URL + `/downloads/sow-head-vfx-manifest.json"}]}`))
		case "/downloads/sow-head-vfx-manifest.json":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(manifestJSON))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", projRoot)
	runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")

	proj := &project.Project{
		Root: projRoot,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				Source: "topdata",
				Build:  "build/topdata",
			},
			Autogen: project.AutogenConfig{
				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",
						},
					},
				},
			},
		},
	}

	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)
	if !strings.Contains(text, "42\theadaccessory_BANDANA\tD\t0\thfx_bandana\t1") {
		t.Fatalf("expected remote manifest entry with preserved lock id, got:\n%s", text)
	}
	if strings.Contains(text, "HAIR_BANGS") {
		t.Fatalf("expected fresh remote manifest to replace cached manifest contents, got:\n%s", text)
	}
}

func TestBuildPackageInvalidatesFreshAutogenCacheWhenReleaseAssetIsNewer(t *testing.T) {
	root := topPackageTestProject(t)
	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.Autogen.Consumers = nil

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("initial BuildAndPackage failed: %v", err)
	}

	sourceTime := time.Now().Add(-6 * time.Hour)
	outputTime := time.Now().Add(-4 * time.Hour)
	setTopDataSourceTimes(t, root, sourceTime)
	setCompiledOutputTimes(t, result, outputTime)

	cachePath := filepath.Join(root, ".cache", "sow-head-vfx-manifest.json")
	writeFile(t, cachePath, `{"id":"head_visualeffects","generated_at":"2026-04-25T10:00:00Z","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`+"\n")
	setFileTime(t, cachePath, time.Now())

	var server *httptest.Server
	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"assets":[{"name":"sow-head-vfx-manifest.json","updated_at":"3026-04-25T12:00:00Z","browser_download_url":"` + server.URL + `/downloads/sow-head-vfx-manifest.json"}]}`))
		case "/downloads/sow-head-vfx-manifest.json":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"id":"head_visualeffects","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", root)
	runGitTest(t, root, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")

	proj.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
		{
			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",
			},
		},
	}

	if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
		t.Fatalf("expected newer remote autogen manifest to invalidate compiled output, got %v", err)
	}
}

func TestBuildNativeRejectsGitRepoTopDataAssetsOverride(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": []
}`+"\n")
	mkdirAll(t, filepath.Join(root, "reference"))
	writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")

	proj := testProject(root)
	proj.Config.TopData.Assets = "ssh://git@gitea.westgate.pw:2222/ShadowsOverWestgate/sow-assets.git"

	_, err := BuildNative(proj, nil)
	if err == nil || !strings.Contains(err.Error(), "no longer supports git repo specs") {
		t.Fatalf("expected git repo topdata.assets override to be rejected, got %v", err)
	}
}

func TestBuildNativeFailsWhenReleasedPartsManifestUnavailable(t *testing.T) {
	root := t.TempDir()
	projRoot := filepath.Join(root, "project")
	mkdirAll(t, filepath.Join(projRoot, "src"))
	mkdirAll(t, filepath.Join(projRoot, "assets"))
	mkdirAll(t, filepath.Join(projRoot, "build"))
	mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
	writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
  "output": "parts_belt.2da",
  "columns": ["COSTMODIFIER", "ACBONUS"],
  "rows": []
}`+"\n")

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.NotFound(w, r)
	}))
	defer server.Close()

	runGitTest(t, "", "init", "-b", "main", projRoot)
	runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")

	proj := &project.Project{
		Root: projRoot,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				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",
						},
					},
				},
			},
		},
	}

	_, err := BuildNative(proj, nil)
	if err == nil || !strings.Contains(err.Error(), "fetch parts manifest release metadata") {
		t.Fatalf("expected missing released parts manifest to fail, got %v", err)
	}
}

func TestBuildAndPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	pngPayload := bytes.Repeat([]byte("png-payload-"), 4096)
	writeBytes(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), pngPayload)

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("BuildAndPackage failed: %v", err)
	}
	if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) {
		t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
	}
	if result.OutputTLKPath != filepath.Join(root, "build", PackageTLKFileName) {
		t.Fatalf("unexpected tlk path: %s", result.OutputTLKPath)
	}

	input, err := os.Open(result.OutputHAKPath)
	if err != nil {
		t.Fatalf("open hak: %v", err)
	}
	defer input.Close()
	archive, err := erf.Read(input)
	if err != nil {
		t.Fatalf("read hak: %v", err)
	}
	found2DA := false
	foundAsset := false
	foundTLK := false
	for _, resource := range archive.Resources {
		ext, _ := erf.ExtensionForResourceType(resource.Type)
		key := strings.ToLower(resource.Name) + "." + ext
		if key == "repadjust.2da" {
			if resource.Type != 0x07E1 {
				t.Fatalf("expected repadjust.2da to keep resource type 0x07E1, got 0x%04X", resource.Type)
			}
			found2DA = true
		}
		if key == "testicon.png" {
			if !bytes.Equal(resource.Data, pngPayload) {
				t.Fatalf("expected testicon.png payload to be preserved, got %d bytes", len(resource.Data))
			}
			foundAsset = true
		}
		if ext == "tlk" {
			foundTLK = true
		}
	}
	if !found2DA {
		t.Fatalf("expected repadjust.2da in top package")
	}
	if !foundAsset {
		t.Fatalf("expected testicon.png in top package")
	}
	if foundTLK {
		t.Fatalf("expected top package hak to exclude tlk resources")
	}
	if _, err := os.Stat(result.OutputTLKPath); err != nil {
		t.Fatalf("expected packaged tlk output: %v", err)
	}
}

func TestCollectTopPackageResourcesRejectsDuplicateTopAssetKeys(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, ".cache", "2da"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui", "icons"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui", "portraits"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, ".cache", "2da", "repadjust.2da"), "2DA V2.0\n\n      Label\n0     TEST_LABEL\n")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "icons", "duplicate.png"), "icon-a")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "portraits", "duplicate.png"), "icon-b")

	proj := testProject(root)
	if _, _, err := collectTopPackageResources(proj, filepath.Join(root, ".cache", "2da")); err == nil || !strings.Contains(err.Error(), "collides with") {
		t.Fatalf("expected duplicate topdata asset collision, got %v", err)
	}
}

func TestBuildAndPackageHonorsConfiguredTopPackageOutputNames(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.TopData.PackageHAK = "custom_top.hak"
	proj.Config.TopData.PackageTLK = "custom_dialog.tlk"

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("BuildAndPackage failed: %v", err)
	}
	if result.OutputHAKPath != proj.TopDataPackageHAKPath() {
		t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
	}
	if result.OutputTLKPath != proj.TopDataPackageTLKPath() {
		t.Fatalf("unexpected tlk path: %s", result.OutputTLKPath)
	}
	if _, err := os.Stat(proj.TopDataPackageHAKPath()); err != nil {
		t.Fatalf("expected configured packaged hak output: %v", err)
	}
	if _, err := os.Stat(proj.TopDataPackageTLKPath()); err != nil {
		t.Fatalf("expected configured packaged tlk output: %v", err)
	}
}

func TestBuildAndPackageNoOpsWhenOutputsAreCurrent(t *testing.T) {
	root := topPackageTestProject(t)
	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.Autogen.Consumers = nil

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("initial BuildAndPackage failed: %v", err)
	}

	sourceTime := time.Now().Add(-4 * time.Hour)
	outputTime := time.Now().Add(-10 * time.Minute)
	setTopDataSourceTimes(t, root, sourceTime)
	setBuildOutputTimes(t, result, outputTime)

	beforeHAK := mustStat(t, result.OutputHAKPath).ModTime()
	beforeTLK := mustStat(t, result.OutputTLKPath).ModTime()
	incremental, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("incremental BuildAndPackage failed: %v", err)
	}
	if incremental.Mode != "incremental-noop" {
		t.Fatalf("expected incremental-noop mode, got %q", incremental.Mode)
	}
	if after := mustStat(t, result.OutputHAKPath).ModTime(); !after.Equal(beforeHAK) {
		t.Fatalf("expected hak mtime to be unchanged, got %s want %s", after, beforeHAK)
	}
	if after := mustStat(t, result.OutputTLKPath).ModTime(); !after.Equal(beforeTLK) {
		t.Fatalf("expected tlk mtime to be unchanged, got %s want %s", after, beforeTLK)
	}
}

func TestBuildPackageFailsWhenAutogenManifestCacheIsStale(t *testing.T) {
	root := topPackageTestProject(t)
	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.Autogen.Consumers = nil

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("initial BuildAndPackage failed: %v", err)
	}

	sourceTime := time.Now().Add(-6 * time.Hour)
	outputTime := time.Now().Add(-4 * time.Hour)
	setTopDataSourceTimes(t, root, sourceTime)
	setCompiledOutputTimes(t, result, outputTime)

	proj.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
		{
			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",
			},
		},
	}
	cachePath := filepath.Join(root, ".cache", "sow-head-vfx-manifest.json")
	writeFile(t, cachePath, `{"id":"head_visualeffects","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`+"\n")
	setFileTime(t, cachePath, time.Now().Add(-2*autogenManifestCacheMaxAge))

	if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
		t.Fatalf("expected stale autogen manifest cache to invalidate compiled output, got %v", err)
	}
}

func TestBuildAndPackageRefreshesPackageWhenCompiledOutputsAreCurrent(t *testing.T) {
	root := topPackageTestProject(t)
	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.Autogen.Consumers = nil

	result, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("initial BuildAndPackage failed: %v", err)
	}

	sourceTime := time.Now().Add(-6 * time.Hour)
	compiledTime := time.Now().Add(-4 * time.Hour)
	packageTime := time.Now().Add(-8 * time.Hour)
	setTopDataSourceTimes(t, root, sourceTime)
	setCompiledOutputTimes(t, result, compiledTime)
	setFileTime(t, result.OutputHAKPath, packageTime)

	compiled2DA := filepath.Join(result.Output2DADir, "repadjust.2da")
	before2DA := mustStat(t, compiled2DA).ModTime()
	refreshed, err := BuildAndPackage(proj, nil)
	if err != nil {
		t.Fatalf("package refresh BuildAndPackage failed: %v", err)
	}
	if refreshed.Mode != "native" {
		t.Fatalf("expected native mode from packaged compiled output, got %q", refreshed.Mode)
	}
	if after := mustStat(t, compiled2DA).ModTime(); !after.Equal(before2DA) {
		t.Fatalf("expected compiled 2da mtime to be unchanged, got %s want %s", after, before2DA)
	}
	if after := mustStat(t, result.OutputHAKPath).ModTime(); !after.After(packageTime) {
		t.Fatalf("expected hak to be refreshed after %s, got %s", packageTime, after)
	}
}

func TestNativeCompileGroupStatsCountSourceFragmentsForBaseDatasets(t *testing.T) {
	root := testProjectRoot(t)
	modulesDir := filepath.Join(root, "placeables", "modules")
	generatedDir := filepath.Join(root, "feat", "generated")
	mkdirAll(t, modulesDir)
	mkdirAll(t, generatedDir)
	writeFile(t, filepath.Join(modulesDir, "a.json"), "{}\n")
	writeFile(t, filepath.Join(modulesDir, "b.json"), "{}\n")
	writeFile(t, filepath.Join(generatedDir, "family.json"), "{}\n")

	stats := nativeCompileGroupStats([]nativeCollectedDataset{
		{
			Dataset: nativeDataset{
				Kind:         nativeDatasetBase,
				Name:         "placeables",
				ModulesDir:   modulesDir,
				GeneratedDir: filepath.Join(root, "missing-generated"),
			},
		},
		{
			Dataset: nativeDataset{
				Kind:         nativeDatasetBase,
				Name:         "feat",
				ModulesDir:   filepath.Join(root, "missing-modules"),
				GeneratedDir: generatedDir,
			},
		},
		{
			Dataset: nativeDataset{
				Kind: nativeDatasetPlain,
				Name: "classes/skills/fighter",
			},
		},
	})

	placeables := stats["placeables"]
	if placeables.OutputTables != 1 || placeables.SourceFragments != 3 {
		t.Fatalf("unexpected placeables stats: %#v", placeables)
	}

	feat := stats["feat"]
	if feat.OutputTables != 1 || feat.SourceFragments != 2 {
		t.Fatalf("unexpected feat stats: %#v", feat)
	}

	classes := stats["classes"]
	if classes.OutputTables != 1 || classes.SourceFragments != 1 {
		t.Fatalf("unexpected classes stats: %#v", classes)
	}
}

func topPackageTestProject(t *testing.T) string {
	t.Helper()
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")
	return root
}

func setTopDataSourceTimes(t *testing.T, root string, modTime time.Time) {
	t.Helper()
	setTreeFileTimes(t, filepath.Join(root, "topdata"), modTime)
}

func setBuildOutputTimes(t *testing.T, result PackageResult, modTime time.Time) {
	t.Helper()
	setCompiledOutputTimes(t, result, modTime)
	setFileTime(t, result.OutputHAKPath, modTime)
	setFileTime(t, result.OutputTLKPath, modTime)
}

func setCompiledOutputTimes(t *testing.T, result PackageResult, modTime time.Time) {
	t.Helper()
	setTreeFileTimes(t, result.Output2DADir, modTime)
	setTreeFileTimes(t, result.OutputTLKDir, modTime)
}

func setTreeFileTimes(t *testing.T, root string, modTime time.Time) {
	t.Helper()
	err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if err := os.Chtimes(path, modTime, modTime); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		t.Fatalf("set tree times under %s: %v", root, err)
	}
}

func setFileTime(t *testing.T, path string, modTime time.Time) {
	t.Helper()
	if err := os.Chtimes(path, modTime, modTime); err != nil {
		t.Fatalf("set file time %s: %v", path, err)
	}
}

func mustStat(t *testing.T, path string) os.FileInfo {
	t.Helper()
	info, err := os.Stat(path)
	if err != nil {
		t.Fatalf("stat %s: %v", path, err)
	}
	return info
}

func diagnosticsContain(diagnostics []Diagnostic, text string) bool {
	for _, diagnostic := range diagnostics {
		if strings.Contains(diagnostic.Message, text) {
			return true
		}
	}
	return false
}

func TestValidateProjectAcceptsPNGTopPackageAsset(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected png topdata asset to validate, got %#v", report.Diagnostics)
	}
}

func TestBuildPackageRequiresExistingCompiledOutput(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""

	if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "run build-topdata first") {
		t.Fatalf("expected missing compiled output error, got %v", err)
	}
}

func TestBuildPackageFailsWhenCompiledOutputIsStale(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	basePath := filepath.Join(root, "topdata", "data", "repadjust", "base.json")
	writeFile(t, basePath, `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""

	if _, err := BuildNative(proj, nil); err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	newTime := time.Now().Add(2 * time.Second)
	if err := os.Chtimes(basePath, newTime, newTime); err != nil {
		t.Fatalf("touch topdata source: %v", err)
	}

	if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
		t.Fatalf("expected stale compiled output error, got %v", err)
	}
}

func TestBuildPackageFailsWhenTopDataSourceFileIsDeleted(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	lockPath := filepath.Join(root, "topdata", "data", "repadjust", "lock.json")
	writeFile(t, lockPath, "{}\n")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""

	if _, err := BuildNative(proj, nil); err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	if err := os.Remove(lockPath); err != nil {
		t.Fatalf("delete topdata source file: %v", err)
	}

	if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
		t.Fatalf("expected deleted source file to invalidate compiled output, got %v", err)
	}
}

func TestBuildPackageIgnoresTemplateSourcesForFreshness(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "templates"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""
	proj.Config.Autogen.Consumers = nil

	if _, err := BuildNative(proj, nil); err != nil {
		t.Fatalf("BuildNative failed: %v", err)
	}

	templatePath := filepath.Join(root, "topdata", "templates", "scratch.2da")
	writeFile(t, templatePath, "2DA V2.0\n\nLabel\n0\tScratch\n")
	newTime := time.Now().Add(2 * time.Second)
	if err := os.Chtimes(templatePath, newTime, newTime); err != nil {
		t.Fatalf("touch topdata template: %v", err)
	}

	if _, err := BuildPackage(proj, nil); err != nil {
		t.Fatalf("expected template sources to be ignored for freshness, got %v", err)
	}
}

func TestValidateProjectWarnsWhenKeyOnlyOverrideMissesCanonicalKeyByCase(t *testing.T) {
	root := testProjectRoot(t)
	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", "baseitems", "base.json"), `{
  "output": "baseitems.2da",
  "columns": ["Label", "MaxRange"],
  "rows": [
    {"id": 0, "key": "baseitems:whip", "Label": "Whip", "MaxRange": "3"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:whip":0}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_baseitems_whip.json"), `{
  "overrides": [
    {"key": "baseitems:Whip", "MaxRange": "4"}
  ]
}`+"\n")

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics))
	}
	if report.WarningCount() == 0 {
		t.Fatal("expected authoring warning for key-only override case mismatch")
	}
	text := diagnosticsText(report.Diagnostics)
	if !strings.Contains(text, `override 0 targets key "baseitems:Whip", but the live canonical key at that point is "baseitems:whip"`) {
		t.Fatalf("expected near-miss override warning, got:\n%s", text)
	}
}

func TestValidateProjectWarnsWhenLockKeepsNormalizedEquivalentKeysAlive(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
	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", "FEAT", "DESCRIPTION"],
  "rows": [
    {"id": 0, "key": "feat:trackless_step", "LABEL": "TRACKLESS_STEP", "FEAT": "100", "DESCRIPTION": "200"}
  ]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
  "feat:trackless_step": 0,
  "feat:tracklessstep": 1200
}`+"\n")

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics))
	}
	if report.WarningCount() == 0 {
		t.Fatal("expected normalized-equivalent lock warning")
	}
	text := diagnosticsText(report.Diagnostics)
	if !strings.Contains(text, `lockfile keeps multiple normalized-equivalent keys alive: feat:trackless_step, feat:tracklessstep`) {
		t.Fatalf("expected normalized-equivalent key warning, got:\n%s", text)
	}
}

func TestBuildNativeSupportsLoosePlainTableAtTopdataDataRoot(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "ruleset.json"), `{
  "columns": ["Name", "Value"],
  "rows": [
    {"id": 0, "Name": "TEST_RULE", "Value": "1"}
  ]
}`+"\n")

	proj := testProject(root)
	proj.Config.TopData.ReferenceBuilder = ""

	report := ValidateProject(proj)
	if report.HasErrors() {
		t.Fatalf("expected loose root table to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
	}

	result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
	if err != nil {
		t.Fatalf("expected loose root table to build: %v", err)
	}
	if _, err := os.Stat(filepath.Join(result.Output2DADir, "ruleset.2da")); err != nil {
		t.Fatalf("expected ruleset.2da output for loose root table: %v", err)
	}
}

func TestValidateProjectDoesNotRewritePlainDatasetLockfile(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	lockPath := filepath.Join(root, "topdata", "data", "ruleset", "lock.json")
	writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "base.json"), `{
  "output": "ruleset.2da",
  "columns": ["Name", "Value"],
  "rows": [
    {"id": 0, "key": "ruleset:test_rule", "Name": "TEST_RULE", "Value": "1"}
  ]
}`+"\n")
	writeFile(t, lockPath, "{}\n")

	before, err := os.ReadFile(lockPath)
	if err != nil {
		t.Fatalf("read initial lockfile: %v", err)
	}

	report := ValidateProject(testProject(root))
	if report.HasErrors() {
		t.Fatalf("expected validation to succeed, got:\n%s", diagnosticsText(report.Diagnostics))
	}

	after, err := os.ReadFile(lockPath)
	if err != nil {
		t.Fatalf("read lockfile after validation: %v", err)
	}
	if string(after) != string(before) {
		t.Fatalf("expected validation to leave plain dataset lockfile unchanged, got:\n%s", string(after))
	}
}

func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
	root := testProjectRoot(t)
	mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
	mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
	writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
	writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
  "output": "repadjust.2da",
  "columns": ["Label"],
  "rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
	writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "repadjust.2da"), "collision")

	report := ValidateProject(testProject(root))
	if !report.HasErrors() {
		t.Fatalf("expected topdata asset collision to be an error")
	}
	if !strings.Contains(diagnosticsText(report.Diagnostics), "collides with compiled topdata output") {
		t.Fatalf("expected collision diagnostic, got %#v", report.Diagnostics)
	}
}

func diagnosticsText(diags []Diagnostic) string {
	lines := make([]string, 0, len(diags))
	for _, diag := range diags {
		lines = append(lines, diag.Message)
	}
	return strings.Join(lines, "\n")
}

func runGitTest(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	if dir != "" {
		cmd.Dir = dir
	}
	output, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
	}
}

func testProjectRoot(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))
	return root
}

func testProject(root string) *project.Project {
	return &project.Project{
		Root: root,
		Config: project.Config{
			Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: project.TopDataConfig{
				Source:           "topdata",
				Build:            ".cache",
				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",
						},
					},
				},
			},
		},
	}
}

func mkdirAll(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(path, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", path, err)
	}
}

func writeFile(t *testing.T, path, content string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

func writeBytes(t *testing.T, path string, content []byte) {
	t.Helper()
	if err := os.WriteFile(path, content, 0o755); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}
