assets builder tools (#39)
build-binaries / build-binaries (push) Successful in 2m5s
test / test (push) Successful in 1m23s

Reviewed-on: #39
Reviewed-by: xtul <mpiasecki720@protonmail.com>
This commit was merged in pull request #39.
This commit is contained in:
2026-07-12 12:51:17 +00:00
parent 87fd3d8c04
commit 5ebef57160
26 changed files with 4625 additions and 10 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)
}
+114
View File
@@ -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")
}
}