assets builder tools (#39)
Reviewed-on: #39 Reviewed-by: xtul <mpiasecki720@protonmail.com>
This commit was merged in pull request #39.
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