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:
2026-07-12 14:13:54 +02:00
co-authored by Claude Opus 4.8
parent 5fff8353da
commit d8f01dc567
2 changed files with 332 additions and 0 deletions
+218
View File
@@ -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)
}