Files
sow-tools/internal/erf/erf_test.go
T
archvillainetteandClaude Opus 5 65d89d4cb8
ci / ci (pull_request) Successful in 3m21s
fix(erf): drop NWN2 formats gr2, wlk and xml from the restype table
Follow-up to the mdb removal in the previous commit. Three of the six
extensions #64 listed as deliberate local additions are NWN2 formats,
not NWN:EE ones, and belong in an NWN:EE tool no more than mdb did.

Checked against xoreos (src/aurora/types.h), which covers every Aurora
game and so distinguishes the games' tables:

  gr2  4003, sits in the NWN2 block (MDB2 4000, MDA2 4001, SPT2 4002,
       GR2 4003, PWC 4008). Granny is NWN2; NWN:EE does not use it.
  wlk  xoreos numbers it 20004 and xml 20003, both above
  xml  kFileTypeMAXArchive in the section headed "Entries for files not
       found in archives with numerical type IDs / Found in NWN2's ZIP
       files". Neither has an archive restype in any Aurora game, so
       Crucible's 0x0BCC and 0x0BCD were invented outright, in a band
       Aurora does use elsewhere (3022 FSM, 3023 ART).

NWN:EE covers those jobs with formats already in the table: wok, pwk
and dwk for walkmeshes, and EE's own UI XML loads from ui/ovr rather
than from a HAK by restype, which is consistent with upstream
neverwinter.nim registering no xml number.

The remaining three local additions stay. lyt 3000, vis 3001 and mdx
3008 are real Aurora archive types that NWN1 ships in its own
data/*.bif; xoreos gives the same numbers. Upstream simply does not
list them.

No asset in the corpus uses gr2, wlk or xml, so nothing stops building.
Also drops them from AssetExtensions, and records the rule in a comment
on the table plus a test, so the next NWN2 format gets caught.

Refs ShadowsOverWestgate/sow-tools#64

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:10:50 +02:00

258 lines
7.8 KiB
Go

package erf
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempPayload(t *testing.T, payload []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "payload.bin")
if err := os.WriteFile(path, payload, 0o644); err != nil {
t.Fatalf("write payload: %v", err)
}
return path
}
func TestWriteRejectsSourcePathSHA256Mismatch(t *testing.T) {
payload := []byte("hello world")
path := writeTempPayload(t, payload)
archive := New("HAK ", []Resource{{
Name: "asset",
Type: 0x0003,
SourcePath: path,
Size: int64(len(payload)),
ExpectedSHA256: strings.Repeat("a", 64),
}})
var buf bytes.Buffer
err := Write(&buf, archive)
if err == nil {
t.Fatal("expected sha256 mismatch error")
}
if !strings.Contains(err.Error(), "sha256 mismatch") {
t.Fatalf("error = %v, want sha256 mismatch", err)
}
}
func TestWriteAcceptsMatchingSourcePathSHA256(t *testing.T) {
payload := []byte("hello world")
path := writeTempPayload(t, payload)
sum := sha256.Sum256(payload)
archive := New("HAK ", []Resource{{
Name: "asset",
Type: 0x0003,
SourcePath: path,
Size: int64(len(payload)),
ExpectedSHA256: hex.EncodeToString(sum[:]),
}})
var buf bytes.Buffer
if err := Write(&buf, archive); err != nil {
t.Fatalf("write: %v", err)
}
decoded, err := Read(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("read: %v", err)
}
if len(decoded.Resources) != 1 || string(decoded.Resources[0].Data) != string(payload) {
t.Fatalf("unexpected payload: %#v", decoded.Resources)
}
}
func TestArchiveRoundTrip(t *testing.T) {
archive := New("MOD ", []Resource{
{Name: "module", Type: 0x07DE, Data: []byte("ifo")},
{Name: "start", Type: 0x07DC, Data: []byte("are")},
})
var buf bytes.Buffer
if err := Write(&buf, archive); err != nil {
t.Fatalf("write: %v", err)
}
decoded, err := Read(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("read: %v", err)
}
if len(decoded.Resources) != 2 {
t.Fatalf("expected 2 resources, got %d", len(decoded.Resources))
}
if decoded.Resources[0].Name != "module" || string(decoded.Resources[0].Data) != "ifo" {
t.Fatalf("unexpected first resource: %#v", decoded.Resources[0])
}
}
func TestExtensionMappingsKeep2DAAndTLKDistinct(t *testing.T) {
resourceType, ok := ResourceTypeForExtension("2da")
if !ok {
t.Fatal("expected 2da resource type")
}
if resourceType != 0x07E1 {
t.Fatalf("expected 2da type 0x07E1, got 0x%04X", resourceType)
}
extension, ok := ExtensionForResourceType(resourceType)
if !ok {
t.Fatal("expected canonical extension for 2da type")
}
if extension != "2da" {
t.Fatalf("expected 2da extension, got %q", extension)
}
tlkType, ok := ResourceTypeForExtension("tlk")
if !ok {
t.Fatal("expected tlk resource type")
}
if tlkType != 0x07E2 {
t.Fatalf("expected tlk type 0x07E2, got 0x%04X", tlkType)
}
if tlkType == resourceType {
t.Fatal("expected 2da and tlk resource types to stay distinct")
}
}
func TestHAKResourceTypeForExtensionSupportsPNG(t *testing.T) {
if resourceType, ok := HAKResourceTypeForExtension("png"); !ok || resourceType != 0x0820 {
t.Fatalf("expected png restype 0x0820 (2080) for HAK generation, got ok=%v type=0x%04X", ok, resourceType)
}
if resourceType, ok := HAKResourceTypeForExtension("2da"); !ok || resourceType != 0x07E1 {
t.Fatalf("expected 2da to stay valid for HAK generation, got ok=%v type=0x%04X", ok, resourceType)
}
}
func TestExtensionMappingsSupportModernAssetTypes(t *testing.T) {
cases := map[string]uint16{
"mtr": 0x0818,
"shd": 0x0815,
"txi": 0x07E6,
"jpg": 0x0821,
"lyt": 0x0BB8,
"vis": 0x0BB9,
"mdx": 0x0BC0,
}
for ext, wantType := range cases {
gotType, ok := ResourceTypeForExtension(ext)
if !ok {
t.Fatalf("expected %s to resolve to a resource type", ext)
}
if gotType != wantType {
t.Fatalf("expected %s type 0x%04X, got 0x%04X", ext, wantType, gotType)
}
gotExt, ok := ExtensionForResourceType(wantType)
if !ok {
t.Fatalf("expected type 0x%04X to resolve back to an extension", wantType)
}
if gotExt != ext {
t.Fatalf("expected type 0x%04X to resolve to %s, got %s", wantType, ext, gotExt)
}
}
}
func TestExtensionMappingsSupportAllUTBlueprintTypes(t *testing.T) {
cases := map[string]uint16{
"uti": 0x07E9,
"utc": 0x07EB,
"utt": 0x07F0,
"uts": 0x07F3,
"ute": 0x07F8,
"utd": 0x07FA,
"utp": 0x07FC,
"utm": 0x0803,
"utg": 0x0807,
"utw": 0x080A,
}
for ext, wantType := range cases {
gotType, ok := ResourceTypeForExtension(ext)
if !ok {
t.Fatalf("expected %s to resolve to a resource type", ext)
}
if gotType != wantType {
t.Fatalf("expected %s type 0x%04X, got 0x%04X", ext, wantType, gotType)
}
gotExt, ok := ExtensionForResourceType(wantType)
if !ok {
t.Fatalf("expected type 0x%04X to resolve back to an extension", wantType)
}
if gotExt != ext {
t.Fatalf("expected type 0x%04X to resolve to %s, got %s", wantType, ext, gotExt)
}
}
}
// TestRestypeTableMatchesUpstream pins the restype numbers Crucible shares with
// neverwinter.nim's restype.nim. The values below are upstream's; a mismatch
// means a HAK we write is mislabelled for the game.
func TestRestypeTableMatchesUpstream(t *testing.T) {
upstream := map[string]uint16{
"res": 0, "bmp": 1, "mve": 2, "tga": 3, "wav": 4, "plt": 6, "ini": 7,
"bmu": 8, "txt": 10, "mdl": 2002, "nss": 2009, "ncs": 2010, "are": 2012,
"set": 2013, "ifo": 2014, "bic": 2015, "wok": 2016, "2da": 2017,
"tlk": 2018, "txi": 2022, "git": 2023, "uti": 2025, "utc": 2027,
"dlg": 2029, "itp": 2030, "utt": 2032, "dds": 2033, "uts": 2035,
"ltr": 2036, "gff": 2037, "fac": 2038, "ute": 2040, "utd": 2042,
"utp": 2044, "dft": 2045, "gic": 2046, "gui": 2047, "utm": 2051,
"dwk": 2052, "pwk": 2053, "utg": 2055, "jrl": 2056, "utw": 2058,
"ssf": 2060, "hak": 2061, "nwm": 2062, "bik": 2063, "ndb": 2064,
"ptm": 2065, "ptt": 2066, "shd": 2069, "mtr": 2072, "lod": 2078,
"gif": 2079, "png": 2080, "jpg": 2081,
}
for ext, want := range upstream {
got, ok := ResourceTypeForExtension(ext)
if !ok {
t.Errorf("%s: not registered, upstream has %d", ext, want)
continue
}
if got != want {
t.Errorf("%s: registered as %d, upstream has %d", ext, got, want)
}
}
// Extensions upstream registers that Crucible must not reuse for anything else.
reserved := map[uint16]string{2039: "bte", 2067: "bak", 2070: "xbc", 2076: "tml"}
for number, upstreamExt := range reserved {
if ext, ok := ExtensionForResourceType(number); ok {
t.Errorf("%d: registered as %s, upstream reserves it for %s", number, ext, upstreamExt)
}
}
}
// TestNWN2FormatsAreNotRegistered keeps NWN2 file formats out of an NWN:EE
// table. mdb and gr2 have Aurora numbers that mean something else (or nothing)
// in NWN:EE; wlk and xml have no archive number in any Aurora game, so the
// values Crucible used for them were invented. Packing any of these into a HAK
// writes a resource the game misreads.
func TestNWN2FormatsAreNotRegistered(t *testing.T) {
for _, ext := range []string{"mdb", "gr2", "wlk", "xml"} {
if number, ok := ResourceTypeForExtension(ext); ok {
t.Errorf("%s is an NWN2 format but is registered as 0x%04X", ext, number)
}
}
}
// TestRestypeTablesAreMutualInverses is the check init() is meant to enforce.
func TestRestypeTablesAreMutualInverses(t *testing.T) {
for ext, number := range extensionTypes {
canonical, ok := typeExtensions[number]
if !ok {
t.Errorf("%s: number 0x%04X missing from typeExtensions", ext, number)
continue
}
if canonical != ext {
t.Errorf("%s: maps to 0x%04X, which maps back to %s", ext, number, canonical)
}
}
for number, ext := range typeExtensions {
if got, ok := extensionTypes[ext]; !ok || got != number {
t.Errorf("0x%04X: maps to %s, which maps back to 0x%04X (ok=%v)", number, ext, got, ok)
}
}
}