// 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) }