Adds `crucible nwsync emit` and `crucible nwsync assemble`, the split replacement for upstream nwn_nwsync_write, which cannot be used because it wants every hak, the TLK and the module present in one run on one disk. emit explodes one artifact — a .hak/.erf or a loose file such as the TLK — into NWSync blobs (NWCompressedBuffer framing, magic NSYC, zstd) plus a NSYM v3 manifest describing only that artifact, with the same .json sidecar upstream writes. Blob names are the sha1 of the uncompressed bytes, restypes nss/ndb/gic are always skipped, an unresolvable restype is a hard error, a resource over 15 MB fails closed, and no latest or .origin file is ever written. assemble merges the per-artifact manifests into one, reading no bulk data. The merge rule is resref shadowing, not concatenation: a resref present in more than one artifact resolves to the earliest artifact in --order, the way the game resolves it. It refuses to merge across mismatched emitter versions, and takes --group-id from the caller (1 current, 2 testing; 0 is absent). Refs #53.
77 lines
2.4 KiB
Go
77 lines
2.4 KiB
Go
package nwsync
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// NWCompressedBuffer framing, as upstream's neverwinter/compressedbuf.nim
|
|
// writes it for NWSync blobs. All fields are little-endian uint32:
|
|
//
|
|
// magic "NSYC", version 3, algorithm 2 (zstd), uncompressed size,
|
|
// zstd header version 1, dictionary 0, then the raw zstd frame.
|
|
const (
|
|
blobMagic = 0x4359534E // "NSYC" little-endian
|
|
blobVersion = 3
|
|
algorithmZstd = 2
|
|
zstdHeaderVer = 1
|
|
zstdDictionary = 0
|
|
blobHeaderBytes = 24
|
|
)
|
|
|
|
var (
|
|
blobEncoder, _ = zstd.NewWriter(nil)
|
|
blobDecoder, _ = zstd.NewReader(nil)
|
|
)
|
|
|
|
// compressBlob wraps data in NWCompressedBuffer framing.
|
|
func compressBlob(data []byte) []byte {
|
|
var out bytes.Buffer
|
|
header := []uint32{blobMagic, blobVersion, algorithmZstd, uint32(len(data)), zstdHeaderVer, zstdDictionary}
|
|
for _, field := range header {
|
|
_ = binary.Write(&out, binary.LittleEndian, field)
|
|
}
|
|
out.Write(blobEncoder.EncodeAll(data, nil))
|
|
return out.Bytes()
|
|
}
|
|
|
|
// decompressBlob unwraps NWCompressedBuffer framing. It exists so a blob this
|
|
// package wrote — or one upstream wrote — can be compared by its uncompressed
|
|
// bytes, which is the only comparison that is meaningful across zstd
|
|
// implementations.
|
|
func decompressBlob(blob []byte) ([]byte, error) {
|
|
if len(blob) < blobHeaderBytes {
|
|
return nil, fmt.Errorf("blob too small: %d bytes", len(blob))
|
|
}
|
|
header := make([]uint32, 6)
|
|
if err := binary.Read(bytes.NewReader(blob[:blobHeaderBytes]), binary.LittleEndian, header); err != nil {
|
|
return nil, fmt.Errorf("decode blob header: %w", err)
|
|
}
|
|
switch {
|
|
case header[0] != blobMagic:
|
|
return nil, fmt.Errorf("invalid blob magic: %#x", header[0])
|
|
case header[1] != blobVersion:
|
|
return nil, fmt.Errorf("unsupported blob version: %d", header[1])
|
|
case header[2] != algorithmZstd:
|
|
return nil, fmt.Errorf("unsupported compression algorithm: %d", header[2])
|
|
case header[4] != zstdHeaderVer:
|
|
return nil, fmt.Errorf("unsupported zstd header version: %d", header[4])
|
|
case header[5] != zstdDictionary:
|
|
return nil, fmt.Errorf("zstd dictionaries are not supported")
|
|
}
|
|
if header[3] == 0 {
|
|
return nil, nil
|
|
}
|
|
data, err := blobDecoder.DecodeAll(blob[blobHeaderBytes:], nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decompress blob: %w", err)
|
|
}
|
|
if uint32(len(data)) != header[3] {
|
|
return nil, fmt.Errorf("blob size mismatch: header says %d, got %d", header[3], len(data))
|
|
}
|
|
return data, nil
|
|
}
|