Files
sow-tools/internal/nwsync/compressedbuf.go
T
archvillainette 0de1c1e280 feat(nwsync): emit blobs and per-artifact NSYM, assemble merged manifests
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.
2026-07-29 12:12:22 +02:00

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
}