feat(assets): pure-Go MDL model-name module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user