diff --git a/internal/assets/mdl/mdl.go b/internal/assets/mdl/mdl.go new file mode 100644 index 0000000..119440e --- /dev/null +++ b/internal/assets/mdl/mdl.go @@ -0,0 +1,218 @@ +// Package mdl ports the ASCII MDL model-name checks from sow-assets-manifest's +// mdl-name-lib.sh + mdl-scan.awk to pure Go. Binary (compiled) MDLs are opaque +// here; the NWN engine is the validator for those. Detection mirrors +// mdl-scan.awk exactly so behavior does not drift while that awk still runs in +// sow-assets-manifest. +package mdl + +import ( + "bytes" + "os" + "path/filepath" + "strings" +) + +// Mismatch is one model-name problem. Line is 1-based; 0 means the geometry +// base-node mismatch, which the source has no single line for. +type Mismatch struct { + Line int + What string + Got string +} + +// ExpectedName is the file stem with a trailing ".mdl" (any case) removed. +func ExpectedName(path string) string { + base := filepath.Base(path) + if len(base) >= 4 && strings.EqualFold(base[len(base)-4:], ".mdl") { + return base[:len(base)-4] + } + return base +} + +// ExpectedNameStem is an alias of ExpectedName kept for call-site clarity where +// the value is used as the engine's model stem argument. +func ExpectedNameStem(path string) string { return ExpectedName(path) } + +// IsASCII reports whether path is an uncompiled ASCII model: no NUL in the +// first 256 bytes, and a leading keyword of '#' / newmodel / node / +// setsupermodel. Mirrors mdl_is_ascii. +func IsASCII(path string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + return isASCII(data), nil +} + +func isASCII(data []byte) bool { + head := data + if len(head) > 256 { + head = head[:256] + } + if bytes.IndexByte(head, 0) >= 0 { + return false + } + line := head + if i := bytes.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + trimmed := strings.TrimLeft(string(line), " \t\r") + if strings.HasPrefix(trimmed, "#") { + return true + } + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return false + } + tok := strings.ToLower(fields[0]) + return strings.HasPrefix(tok, "newmodel") || + strings.HasPrefix(tok, "node") || + strings.HasPrefix(tok, "setsupermodel") +} + +// lineFields splits a line into whitespace-delimited tokens with the trailing +// CR removed, matching awk's default field split + \r strip. +func lineFields(line string) []string { + return strings.Fields(strings.TrimRight(line, "\r")) +} + +// field returns the 1-based nth field or "" if absent. +func field(f []string, n int) string { + if n >= 1 && n <= len(f) { + return f[n-1] + } + return "" +} + +// CheckNames returns every model-name mismatch in an ASCII model. Returns nil +// for a binary model or one with no newmodel line (not a real model). +func CheckNames(path string) ([]Mismatch, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if !isASCII(data) { + return nil, nil + } + return checkNames(data, ExpectedName(path)), nil +} + +func checkNames(data []byte, expected string) []Mismatch { + expLower := strings.ToLower(expected) + var out []Mismatch + seenModel := false + inGeom := false + curNode := "" + base := "" + baseFound := false + + // header records one header-token mismatch (field n, 1-based) if the token + // is present and differs case-insensitively from the stem. + lines := strings.Split(string(data), "\n") + for i, raw := range lines { + f := lineFields(raw) + if len(f) == 0 { + continue + } + key := strings.ToLower(f[0]) + mis := func(what string, n int) { + got := field(f, n) + if got != "" && !strings.EqualFold(got, expected) { + out = append(out, Mismatch{Line: i + 1, What: what, Got: got}) + } + } + switch key { + case "newmodel": + seenModel = true + mis("newmodel", 2) + case "setsupermodel": + mis("setsupermodel model", 2) + case "beginmodelgeom": + inGeom = true + mis("beginmodelgeom", 2) + case "endmodelgeom": + inGeom = false + mis("endmodelgeom", 2) + case "donemodel": + mis("donemodel", 2) + case "newanim": + mis("newanim model", 3) + case "doneanim": + mis("doneanim model", 3) + case "node": + if inGeom { + curNode = field(f, 3) + } + case "parent": + if inGeom && !baseFound && strings.EqualFold(field(f, 2), "null") { + base = curNode + baseFound = true + } + } + } + if !seenModel { + return nil + } + if baseFound && base != "" && !strings.EqualFold(base, expLower) { + out = append(out, Mismatch{Line: 0, What: "root node", Got: base}) + } + return out +} + +// FixNames rewrites only the model identity to expected: the header tokens plus +// the geometry base node's declaration / any parent / animroot pointing at the +// old base name. Animation bone names and supermodel animroots are left alone +// so inheritance keeps working. Mirrors mdl_fix_model_name in intent. +// +// ponytail: a rewritten line is rebuilt joining fields with single spaces and +// drops a trailing CR, exactly like the awk it replaces; unchanged lines are +// byte-identical. The engine ignores identity-line whitespace, and FixNames' +// contract is only "produces a model that passes CheckNames", not byte parity. +func FixNames(in []byte, expected string) (out []byte, changed bool) { + if !isASCII(in) { + return in, false + } + // oldbase: the base-node name to rename, only if it differs from expected. + oldbase := "" + for _, m := range checkNames(in, expected) { + if m.What == "root node" { + oldbase = m.Got + } + } + + lines := strings.Split(string(in), "\n") + for idx, raw := range lines { + f := lineFields(raw) + if len(f) == 0 { + continue + } + key := strings.ToLower(f[0]) + set := func(n int) bool { + if field(f, n) == "" { + return false + } + f[n-1] = expected + return true + } + modified := false + switch key { + case "newmodel", "setsupermodel", "beginmodelgeom", "endmodelgeom", "donemodel": + modified = set(2) + case "newanim", "doneanim": + modified = set(3) + case "node": + if oldbase != "" && strings.EqualFold(field(f, 3), oldbase) { + modified = set(3) + } + case "parent", "animroot": + if oldbase != "" && strings.EqualFold(field(f, 2), oldbase) { + modified = set(2) + } + } + if modified { + lines[idx] = strings.Join(f, " ") + } + } + out = []byte(strings.Join(lines, "\n")) + return out, !bytes.Equal(out, in) +} diff --git a/internal/assets/mdl/mdl_test.go b/internal/assets/mdl/mdl_test.go new file mode 100644 index 0000000..6fc0dee --- /dev/null +++ b/internal/assets/mdl/mdl_test.go @@ -0,0 +1,114 @@ +package mdl + +import ( + "os" + "path/filepath" + "testing" +) + +func writeMDL(t *testing.T, name, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestIsASCII(t *testing.T) { + ascii := writeMDL(t, "foo.mdl", "newmodel foo\nbeginmodelgeom foo\n") + if ok, err := IsASCII(ascii); err != nil || !ok { + t.Fatalf("ascii: ok=%v err=%v, want true nil", ok, err) + } + bin := writeMDL(t, "bar.mdl", "\x00\x01binary\x00garbage") + if ok, err := IsASCII(bin); err != nil || ok { + t.Fatalf("binary: ok=%v err=%v, want false nil", ok, err) + } + hash := writeMDL(t, "baz.mdl", "# a comment\nnewmodel baz\n") + if ok, _ := IsASCII(hash); !ok { + t.Fatal("leading # should be ascii") + } +} + +func TestExpectedName(t *testing.T) { + for in, want := range map[string]string{ + "a/b/Foo.MDL": "Foo", + "waxbt_b_091.mdl": "waxbt_b_091", + "x.mdl": "x", + } { + if got := ExpectedName(in); got != want { + t.Errorf("ExpectedName(%q)=%q want %q", in, got, want) + } + } +} + +func TestCheckNames(t *testing.T) { + // Header token mismatch + base-node mismatch in one file. + body := "newmodel wrong\n" + + "setsupermodel wrong a_base\n" + + "beginmodelgeom foo\n" + + " node dummy Wmgst_m_081\n" + + " parent null\n" + + " endnode\n" + + "endmodelgeom foo\n" + + "donemodel foo\n" + p := writeMDL(t, "foo.mdl", body) + got, err := CheckNames(p) + if err != nil { + t.Fatal(err) + } + // Expected stem "foo": newmodel(wrong), setsupermodel(wrong), base node(Wmgst_m_081). + if len(got) != 3 { + t.Fatalf("got %d mismatches, want 3: %+v", len(got), got) + } + if got[0].What != "newmodel" || got[0].Got != "wrong" || got[0].Line != 1 { + t.Errorf("first mismatch = %+v", got[0]) + } + base := got[len(got)-1] + if base.What != "root node" || base.Got != "Wmgst_m_081" || base.Line != 0 { + t.Errorf("base mismatch = %+v", base) + } + + // Clean model -> no mismatches. + clean := writeMDL(t, "bar.mdl", + "newmodel bar\nbeginmodelgeom bar\n node dummy bar\n parent null\n endnode\nendmodelgeom bar\ndonemodel bar\n") + if got, _ := CheckNames(clean); len(got) != 0 { + t.Fatalf("clean model reported mismatches: %+v", got) + } + + // Binary model -> nil, no error. + bin := writeMDL(t, "b.mdl", "\x00\x00binary") + if got, _ := CheckNames(bin); got != nil { + t.Fatalf("binary reported mismatches: %+v", got) + } +} + +func TestFixNamesRoundTrips(t *testing.T) { + body := "newmodel wrong\n" + + "setsupermodel wrong\n" + + "beginmodelgeom wrong\n" + + " node dummy Wmgst_m_081\n" + + " parent null\n" + + " endnode\n" + + "endmodelgeom wrong\n" + + "donemodel wrong\n" + out, changed := FixNames([]byte(body), "foo") + if !changed { + t.Fatal("expected changed=true") + } + p := filepath.Join(t.TempDir(), "foo.mdl") + if err := os.WriteFile(p, out, 0o644); err != nil { + t.Fatal(err) + } + got, err := CheckNames(p) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("fixed model still has mismatches: %+v\n%s", got, out) + } + // Idempotent: fixing a clean model changes nothing. + if _, changed := FixNames(out, "foo"); changed { + t.Fatal("second fix reported a change") + } +}