feat(nwsync): emit blobs and per-artifact NSYM, assemble merged manifests #71
@@ -19,6 +19,7 @@ Crucible is how the artifact repos turn source into artifacts.
|
|||||||
| `crucible-depot` | `crucible depot` | content-addressed depot blob verify/move |
|
| `crucible-depot` | `crucible depot` | content-addressed depot blob verify/move |
|
||||||
| `crucible-hak` | `crucible hak` | ERF/HAK pack/unpack + hak manifests |
|
| `crucible-hak` | `crucible hak` | ERF/HAK pack/unpack + hak manifests |
|
||||||
| `crucible-module` | `crucible module` | build/extract/validate/compare the `.mod` |
|
| `crucible-module` | `crucible module` | build/extract/validate/compare the `.mod` |
|
||||||
|
| `crucible-nwsync` | `crucible nwsync` | NWSync blob emit + manifest assemble |
|
||||||
| `crucible-topdata` | `crucible topdata` | compile 2da/tlk topdata + packages |
|
| `crucible-topdata` | `crucible topdata` | compile 2da/tlk topdata + packages |
|
||||||
| `crucible-wiki` | `crucible wiki` | render + deploy mechanical wiki pages |
|
| `crucible-wiki` | `crucible wiki` | render + deploy mechanical wiki pages |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Command crucible-nwsync is the standalone nwsync builder (equivalent to
|
||||||
|
// `crucible nwsync`). A single-token binary keeps consumer wrapper scripts simple.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() { os.Exit(dispatch.RunBuilder("nwsync", os.Args[1:])) }
|
||||||
@@ -34,11 +34,20 @@ aliases.
|
|||||||
| `depot` | `verify` | Existence sweep plus sampled download re-hash. |
|
| `depot` | `verify` | Existence sweep plus sampled download re-hash. |
|
||||||
| `depot` | `get` | Fetch one blob with sha re-verify. |
|
| `depot` | `get` | Fetch one blob with sha re-verify. |
|
||||||
| `depot` | `pull` | Incremental verified pull of every referenced blob. |
|
| `depot` | `pull` | Incremental verified pull of every referenced blob. |
|
||||||
|
| `nwsync` | `emit` | Explode one artifact into NWSync blobs plus its own NSYM manifest. |
|
||||||
|
| `nwsync` | `assemble` | Merge per-artifact NSYM manifests into one merged manifest. |
|
||||||
|
|
||||||
`depot status` and `depot get` pick their backend either with `--out DIR`, a
|
`depot status` and `depot get` pick their backend either with `--out DIR`, a
|
||||||
depot tree on disk, or with `--target bunny|cdn`, a remote backend. The two
|
depot tree on disk, or with `--target bunny|cdn`, a remote backend. The two
|
||||||
flags are mutually exclusive.
|
flags are mutually exclusive.
|
||||||
|
|
||||||
|
`nwsync emit` runs where an artifact is born (a `.hak`/`.erf`, or a loose file
|
||||||
|
such as the TLK); `nwsync assemble` runs at module release and reads only the
|
||||||
|
small per-artifact manifests. `--order` lists artifact names highest priority
|
||||||
|
first: a resref in more than one artifact resolves to the earliest one, the way
|
||||||
|
the game resolves it. `--group-id` is per channel — 1 is current, 2 is testing,
|
||||||
|
and 0 leaves the field out of the sidecar.
|
||||||
|
|
||||||
## Hidden compatibility aliases
|
## Hidden compatibility aliases
|
||||||
|
|
||||||
Existing scripts may continue using these names indefinitely. They are accepted
|
Existing scripts may continue using these names indefinitely. They are accepted
|
||||||
|
|||||||
@@ -22,12 +22,13 @@
|
|||||||
pname = "crucible";
|
pname = "crucible";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-hm6mrNAtXv0LidzHUfz4eukTFZouizGtxkZ8gKJFUVI=";
|
vendorHash = "sha256-0I8j7On9YGD2GK9xbj/KkgBrlkMJ6Y6XQv+KCLTgBBU=";
|
||||||
subPackages = [
|
subPackages = [
|
||||||
"cmd/crucible"
|
"cmd/crucible"
|
||||||
"cmd/crucible-depot"
|
"cmd/crucible-depot"
|
||||||
"cmd/crucible-hak"
|
"cmd/crucible-hak"
|
||||||
"cmd/crucible-module"
|
"cmd/crucible-module"
|
||||||
|
"cmd/crucible-nwsync"
|
||||||
"cmd/crucible-topdata"
|
"cmd/crucible-topdata"
|
||||||
"cmd/crucible-wiki"
|
"cmd/crucible-wiki"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ require (
|
|||||||
golang.org/x/text v0.35.0
|
golang.org/x/text v0.35.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/klauspost/compress v1.19.1
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/depot"
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/depot"
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu"
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu"
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/nwsync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exit codes follow the sysexits(3) convention so CI can distinguish
|
// Exit codes follow the sysexits(3) convention so CI can distinguish
|
||||||
@@ -104,6 +105,16 @@ var Registry = []Builder{
|
|||||||
},
|
},
|
||||||
Wired: true,
|
Wired: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "nwsync",
|
||||||
|
Bin: "crucible-nwsync",
|
||||||
|
Summary: "publish NWSync blobs and manifests (emit/assemble)",
|
||||||
|
Commands: []Command{
|
||||||
|
{Name: "emit", Summary: "explode one artifact into blobs plus its own NSYM manifest", Usage: "crucible nwsync emit <artifact> --out DIR"},
|
||||||
|
{Name: "assemble", Summary: "merge per-artifact NSYM manifests into one", Usage: "crucible nwsync assemble --order NAMES --entries DIR --out DIR [--group-id N]"},
|
||||||
|
},
|
||||||
|
Wired: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "assets",
|
Name: "assets",
|
||||||
Bin: "crucible-assets",
|
Bin: "crucible-assets",
|
||||||
@@ -241,7 +252,7 @@ var Registry = []Builder{
|
|||||||
"2da-to-module [flags] <input.2da> [output.json]",
|
"2da-to-module [flags] <input.2da> [output.json]",
|
||||||
"json-to-2da <input.json> <output.2da>",
|
"json-to-2da <input.json> <output.2da>",
|
||||||
},
|
},
|
||||||
Aliases: []CommandAlias{{Name: "convert-topdata"}},
|
Aliases: []CommandAlias{{Name: "convert-topdata"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Wired: true,
|
Wired: true,
|
||||||
@@ -392,7 +403,7 @@ func runBuilder(name string, args []string, out, errw io.Writer) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if b.Wired {
|
if b.Wired {
|
||||||
// depot and assets are self-contained builders: they parse their own
|
// depot, assets and nwsync are self-contained builders: they parse their own
|
||||||
// subcommands and own their exit contract, bypassing the
|
// subcommands and own their exit contract, bypassing the
|
||||||
// b.Commands/delegateLegacy legacy routing entirely.
|
// b.Commands/delegateLegacy legacy routing entirely.
|
||||||
switch b.Name {
|
switch b.Name {
|
||||||
@@ -400,6 +411,8 @@ func runBuilder(name string, args []string, out, errw io.Writer) int {
|
|||||||
return depot.Run(args, out, errw, os.Getenv)
|
return depot.Run(args, out, errw, os.Getenv)
|
||||||
case "assets":
|
case "assets":
|
||||||
return assets.Run(args, out, errw, os.Getenv)
|
return assets.Run(args, out, errw, os.Getenv)
|
||||||
|
case "nwsync":
|
||||||
|
return nwsync.Run(args, out, errw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !b.Wired {
|
if !b.Wired {
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ func TestBuilderHelpIsOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selfContained builders parse their own subcommands instead of delegating to
|
||||||
|
// the legacy internal/app surface.
|
||||||
|
var selfContained = map[string]bool{"depot": true, "assets": true, "nwsync": true}
|
||||||
|
|
||||||
func TestCanonicalCommandSurface(t *testing.T) {
|
func TestCanonicalCommandSurface(t *testing.T) {
|
||||||
want := map[string][]string{
|
want := map[string][]string{
|
||||||
"depot": {"status", "push", "verify", "get", "pull"},
|
"depot": {"status", "push", "verify", "get", "pull"},
|
||||||
@@ -156,6 +160,7 @@ func TestCanonicalCommandSurface(t *testing.T) {
|
|||||||
"module": {"build", "extract", "validate", "compare", "manifest"},
|
"module": {"build", "extract", "validate", "compare", "manifest"},
|
||||||
"topdata": {"validate", "build", "package", "compare", "convert"},
|
"topdata": {"validate", "build", "package", "compare", "convert"},
|
||||||
"wiki": {"build", "deploy"},
|
"wiki": {"build", "deploy"},
|
||||||
|
"nwsync": {"emit", "assemble"},
|
||||||
}
|
}
|
||||||
for _, builder := range Registry {
|
for _, builder := range Registry {
|
||||||
got := builder.subcommands()
|
got := builder.subcommands()
|
||||||
@@ -173,10 +178,10 @@ func TestRegistryCommandNamesAndAliasesAreUnambiguous(t *testing.T) {
|
|||||||
for _, builder := range Registry {
|
for _, builder := range Registry {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, command := range builder.Commands {
|
for _, command := range builder.Commands {
|
||||||
// depot and assets parse their own subcommands and bypass AppCommand
|
// depot, assets and nwsync parse their own subcommands and bypass
|
||||||
// routing entirely (see the self-contained-builder branch in
|
// AppCommand routing entirely (see the self-contained-builder branch
|
||||||
// runBuilder), so their Commands carry no AppCommand.
|
// in runBuilder), so their Commands carry no AppCommand.
|
||||||
requireAppCommand := builder.Name != "depot" && builder.Name != "assets"
|
requireAppCommand := !selfContained[builder.Name]
|
||||||
if command.Name == "" || command.Summary == "" || command.Usage == "" || (requireAppCommand && command.AppCommand == "") {
|
if command.Name == "" || command.Summary == "" || command.Usage == "" || (requireAppCommand && command.AppCommand == "") {
|
||||||
t.Errorf("%s has incomplete command metadata: %#v", builder.Name, command)
|
t.Errorf("%s has incomplete command metadata: %#v", builder.Name, command)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package nwsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssembleOptions describes one merged manifest.
|
||||||
|
type AssembleOptions struct {
|
||||||
|
Order []string // artifact names, highest priority first
|
||||||
|
EntriesDir string // directory holding <name>.nsym and <name>.nsym.json
|
||||||
|
OutDir string // repository root; the manifest lands in <out>/manifests
|
||||||
|
GroupID int // 1 = current, 2 = testing; 0 means absent
|
||||||
|
ModuleName string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleResult reports what one assemble run produced.
|
||||||
|
type AssembleResult struct {
|
||||||
|
SHA1 string
|
||||||
|
ManifestPath string
|
||||||
|
Entries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble merges the per-artifact NSYM manifests named by Order into one
|
||||||
|
// manifest. It reads no bulk data at all — only the small index files.
|
||||||
|
//
|
||||||
|
// Merge rule is resref shadowing, not concatenation: a resref present in more
|
||||||
|
// than one artifact resolves to the earliest artifact in Order, which is how
|
||||||
|
// the game resolves it (upstream's resman adds haks in reverse and lets the
|
||||||
|
// last one win). Get this backwards and the wrong texture ships silently.
|
||||||
|
func Assemble(options AssembleOptions) (AssembleResult, error) {
|
||||||
|
if len(options.Order) == 0 {
|
||||||
|
return AssembleResult{}, fmt.Errorf("assemble: --order names no artifacts")
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := make([]Entry, 0, 1024)
|
||||||
|
winner := make(map[Identity]bool, 1024)
|
||||||
|
var onDiskBytes int64
|
||||||
|
|
||||||
|
for _, name := range options.Order {
|
||||||
|
manifestPath := filepath.Join(options.EntriesDir, name+".nsym")
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return AssembleResult{}, fmt.Errorf("assemble: no index for %q: %w", name, err)
|
||||||
|
}
|
||||||
|
entries, err := readManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
return AssembleResult{}, fmt.Errorf("%s: %w", manifestPath, err)
|
||||||
|
}
|
||||||
|
sidecar, err := readSidecar(manifestPath + ".json")
|
||||||
|
if err != nil {
|
||||||
|
return AssembleResult{}, err
|
||||||
|
}
|
||||||
|
// Two producers of blobs means a skewed emitter can write blobs the
|
||||||
|
// merged manifest quietly disagrees with. Refuse to merge across
|
||||||
|
// mismatched emitter versions.
|
||||||
|
if sidecar.EmitterVersion != emitterVersion {
|
||||||
|
return AssembleResult{}, fmt.Errorf(
|
||||||
|
"assemble: emitter version mismatch: %s was emitted by emitter %q, this is emitter %q",
|
||||||
|
name, sidecar.EmitterVersion, emitterVersion)
|
||||||
|
}
|
||||||
|
// on_disk_bytes overcounts by the handful of cross-artifact
|
||||||
|
// duplicates. It is a display statistic; no dedupe pass for it.
|
||||||
|
onDiskBytes += sidecar.OnDiskBytes
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
identity := entry.identity()
|
||||||
|
if winner[identity] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
winner[identity] = true
|
||||||
|
merged = append(merged, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) == 0 {
|
||||||
|
return AssembleResult{}, fmt.Errorf("assemble: merged manifest is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := writeManifest(merged)
|
||||||
|
if err != nil {
|
||||||
|
return AssembleResult{}, err
|
||||||
|
}
|
||||||
|
sha1Hex := fmt.Sprintf("%x", sha1.Sum(data))
|
||||||
|
manifestPath := filepath.Join(options.OutDir, "manifests", sha1Hex)
|
||||||
|
sidecar := Sidecar{
|
||||||
|
ModuleName: options.ModuleName,
|
||||||
|
Description: options.Description,
|
||||||
|
GroupID: options.GroupID,
|
||||||
|
}
|
||||||
|
if err := writeManifestPair(manifestPath, data, merged, onDiskBytes, sidecar); err != nil {
|
||||||
|
return AssembleResult{}, err
|
||||||
|
}
|
||||||
|
return AssembleResult{SHA1: sha1Hex, ManifestPath: manifestPath, Entries: len(merged)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSidecar(path string) (Sidecar, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Sidecar{}, fmt.Errorf("assemble: missing sidecar: %w", err)
|
||||||
|
}
|
||||||
|
var sidecar Sidecar
|
||||||
|
if err := json.Unmarshal(data, &sidecar); err != nil {
|
||||||
|
return Sidecar{}, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return sidecar, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package nwsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileSizeLimit matches upstream's --limit-file-size default of 15 MB. A
|
||||||
|
// resource over it is a hard failure, not a skip: upstream quit(1)s and so do
|
||||||
|
// we. Our largest resource today is 13.66 MiB, so the headroom is thin.
|
||||||
|
const fileSizeLimit = 15 * 1024 * 1024
|
||||||
|
|
||||||
|
// skippedTypes are never published, matching upstream's GobalResTypeSkipList.
|
||||||
|
var skippedTypes = resTypes("nss", "ndb", "gic")
|
||||||
|
|
||||||
|
// emitterVersion identifies the blob/manifest byte format this package
|
||||||
|
// produces. assemble refuses to merge indexes that disagree on it, because two
|
||||||
|
// producers of blobs mean a skewed emitter can otherwise write blobs the merged
|
||||||
|
// manifest quietly disagrees with. Bump it only when emitted bytes change — it
|
||||||
|
// is deliberately not the build revision, which would invalidate every
|
||||||
|
// published index on every unrelated commit.
|
||||||
|
const emitterVersion = "1"
|
||||||
|
|
||||||
|
// serverTypes are loaded only server-side; a manifest holding nothing else
|
||||||
|
// has no client contents. Mirrors upstream's GlobalResTypeServerList, whose
|
||||||
|
// trailing 0 is RESTYPE_INVALID.
|
||||||
|
var serverTypes = append(resTypes(
|
||||||
|
"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl", "ncs", "ndb",
|
||||||
|
"nss", "ptm", "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
|
||||||
|
), 0)
|
||||||
|
|
||||||
|
func resTypes(extensions ...string) []uint16 {
|
||||||
|
types := make([]uint16, 0, len(extensions))
|
||||||
|
for _, extension := range extensions {
|
||||||
|
restype, ok := erf.ResourceTypeForExtension(extension)
|
||||||
|
if !ok {
|
||||||
|
panic("nwsync: unknown restype " + extension)
|
||||||
|
}
|
||||||
|
types = append(types, restype)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitResult reports what one emit run produced.
|
||||||
|
type EmitResult struct {
|
||||||
|
Name string // artifact name, without extension
|
||||||
|
ManifestPath string
|
||||||
|
Entries int
|
||||||
|
BlobsWritten int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit explodes one artifact — a .hak/.erf/.mod or a loose file such as the
|
||||||
|
// TLK — into NWSync blobs plus a NSYM manifest describing only that artifact.
|
||||||
|
func Emit(artifactPath, outDir string) (EmitResult, error) {
|
||||||
|
resources, err := readArtifact(artifactPath)
|
||||||
|
if err != nil {
|
||||||
|
return EmitResult{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(filepath.Base(artifactPath), filepath.Ext(artifactPath))
|
||||||
|
|
||||||
|
entries, blobs, onDiskBytes, err := emitResources(resources, outDir)
|
||||||
|
if err != nil {
|
||||||
|
return EmitResult{}, err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return EmitResult{}, fmt.Errorf("%s: nothing to index (no publishable resources)", artifactPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := writeManifest(entries)
|
||||||
|
if err != nil {
|
||||||
|
return EmitResult{}, err
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(outDir, name+".nsym")
|
||||||
|
if err := writeManifestPair(manifestPath, data, entries, onDiskBytes, Sidecar{ModuleName: name}); err != nil {
|
||||||
|
return EmitResult{}, err
|
||||||
|
}
|
||||||
|
return EmitResult{Name: name, ManifestPath: manifestPath, Entries: len(entries), BlobsWritten: blobs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readArtifact returns the resources of an ERF/HAK/MOD, or the single resource
|
||||||
|
// a loose file represents. Upstream's resman does the same dispatch on the
|
||||||
|
// file's first three bytes.
|
||||||
|
func readArtifact(path string) ([]erf.Resource, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read artifact: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) >= 3 {
|
||||||
|
switch string(data[:3]) {
|
||||||
|
case "ERF", "HAK":
|
||||||
|
archive, err := erf.Read(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return archive.Resources, nil
|
||||||
|
case "MOD":
|
||||||
|
// A persistent world never publishes module contents, so the .mod
|
||||||
|
// contributes no bytes to a manifest — it only says which haks and
|
||||||
|
// which TLK the manifest covers.
|
||||||
|
return nil, fmt.Errorf("%s: a module is never emitted; a manifest is haks plus the TLK", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
extension := filepath.Ext(base)
|
||||||
|
restype, ok := erf.ResourceTypeForExtension(extension)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: unknown resource type %q", path, extension)
|
||||||
|
}
|
||||||
|
return []erf.Resource{{
|
||||||
|
Name: strings.TrimSuffix(base, extension),
|
||||||
|
Type: restype,
|
||||||
|
Data: data,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitResources(resources []erf.Resource, outDir string) ([]Entry, int, int64, error) {
|
||||||
|
// A resref appearing twice inside one artifact resolves to the last one,
|
||||||
|
// the way resman lets the last container added win.
|
||||||
|
order := make([]Identity, 0, len(resources))
|
||||||
|
latest := make(map[Identity]erf.Resource, len(resources))
|
||||||
|
var tooBig []string
|
||||||
|
for _, resource := range resources {
|
||||||
|
if _, ok := erf.ExtensionForResourceType(resource.Type); !ok {
|
||||||
|
return nil, 0, 0, fmt.Errorf("resref %s is not resolvable (unknown restype %d)", resource.Name, resource.Type)
|
||||||
|
}
|
||||||
|
if slices.Contains(skippedTypes, resource.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(resource.Data) > fileSizeLimit {
|
||||||
|
tooBig = append(tooBig, fmt.Sprintf("%s: %d bytes > %d", resource.Name, len(resource.Data), fileSizeLimit))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
identity := Identity{ResRef: strings.ToLower(resource.Name), ResType: resource.Type}
|
||||||
|
if _, seen := latest[identity]; !seen {
|
||||||
|
order = append(order, identity)
|
||||||
|
}
|
||||||
|
latest[identity] = resource
|
||||||
|
}
|
||||||
|
if len(tooBig) > 0 {
|
||||||
|
sort.Strings(tooBig)
|
||||||
|
return nil, 0, 0, fmt.Errorf("resources exceed the file size limit:\n %s", strings.Join(tooBig, "\n "))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]Entry, 0, len(order))
|
||||||
|
var blobs int
|
||||||
|
var onDiskBytes int64
|
||||||
|
for _, identity := range order {
|
||||||
|
resource := latest[identity]
|
||||||
|
sum := sha1.Sum(resource.Data)
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
SHA1: sum,
|
||||||
|
Size: uint32(len(resource.Data)),
|
||||||
|
ResRef: identity.ResRef,
|
||||||
|
ResType: identity.ResType,
|
||||||
|
})
|
||||||
|
written, err := writeBlob(outDir, sum, resource.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
if written > 0 {
|
||||||
|
blobs++
|
||||||
|
onDiskBytes += written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries, blobs, onDiskBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// created is the sidecar timestamp. SOURCE_DATE_EPOCH pins it so a build can
|
||||||
|
// be reproduced byte for byte; the manifest itself is deterministic already.
|
||||||
|
func created() int64 {
|
||||||
|
if raw := os.Getenv("SOURCE_DATE_EPOCH"); raw != "" {
|
||||||
|
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBlob writes one NWCompressedBuffer blob and returns its size on disk,
|
||||||
|
// or 0 if the blob already existed. Blob names are content hashes, so an
|
||||||
|
// existing name is existing content.
|
||||||
|
func writeBlob(outDir string, sum [20]byte, data []byte) (int64, error) {
|
||||||
|
path := blobPath(outDir, fmt.Sprintf("%x", sum))
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return 0, fmt.Errorf("create blob directory: %w", err)
|
||||||
|
}
|
||||||
|
blob := compressBlob(data)
|
||||||
|
if err := os.WriteFile(path, blob, 0o644); err != nil {
|
||||||
|
return 0, fmt.Errorf("write blob: %w", err)
|
||||||
|
}
|
||||||
|
return int64(len(blob)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeManifestPair writes a NSYM manifest and its .json sidecar. The caller
|
||||||
|
// supplies the sidecar fields it knows; the rest are derived from the entries.
|
||||||
|
// data must be the serialised form of entries.
|
||||||
|
func writeManifestPair(manifestPath string, data []byte, entries []Entry, onDiskBytes int64, sidecar Sidecar) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create manifest directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(manifestPath, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalBytes int64
|
||||||
|
clientContents := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
totalBytes += int64(entry.Size)
|
||||||
|
if !slices.Contains(serverTypes, entry.ResType) {
|
||||||
|
clientContents = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sidecar.Version = manifestVersion
|
||||||
|
sidecar.SHA1 = fmt.Sprintf("%x", sha1.Sum(data))
|
||||||
|
sidecar.HashTreeDepth = hashTreeDepth
|
||||||
|
sidecar.IncludesModuleContents = false
|
||||||
|
sidecar.IncludesClientContents = clientContents
|
||||||
|
sidecar.TotalFiles = len(entries)
|
||||||
|
sidecar.TotalBytes = totalBytes
|
||||||
|
sidecar.OnDiskBytes = onDiskBytes
|
||||||
|
sidecar.Created = created()
|
||||||
|
sidecar.CreatedWith = buildinfo.String()
|
||||||
|
sidecar.EmitterVersion = emitterVersion
|
||||||
|
|
||||||
|
body, err := marshalSidecar(sidecar)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(manifestPath+".json", body, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write sidecar: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package nwsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NSYM manifest, version 3, exactly as upstream neverwinter/nwsync.nim writes
|
||||||
|
// it. Everything is little-endian:
|
||||||
|
//
|
||||||
|
// "NSYM", uint32 version, uint32 entry count, uint32 mapping count,
|
||||||
|
// entries: byte[20] raw sha1, uint32 size, char[16] resref, uint16 restype
|
||||||
|
// mappings: uint32 entry index, char[16] resref, uint16 restype
|
||||||
|
//
|
||||||
|
// Entries are sorted by lowercase sha1 hex then resref, and a resource whose
|
||||||
|
// sha1 was already written becomes a mapping instead of a second entry.
|
||||||
|
const (
|
||||||
|
manifestVersion = 3
|
||||||
|
hashTreeDepth = 2
|
||||||
|
resRefBytes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one resource in a manifest.
|
||||||
|
type Entry struct {
|
||||||
|
SHA1 [20]byte
|
||||||
|
Size uint32
|
||||||
|
ResRef string // lowercase, no extension, at most 16 characters
|
||||||
|
ResType uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Entry) sha1Hex() string { return hex.EncodeToString(e.SHA1[:]) }
|
||||||
|
|
||||||
|
// Identity is what a resref resolves by: name plus type. It is the merge key
|
||||||
|
// inside one artifact and across artifacts alike.
|
||||||
|
type Identity struct {
|
||||||
|
ResRef string
|
||||||
|
ResType uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Entry) identity() Identity { return Identity{ResRef: e.ResRef, ResType: e.ResType} }
|
||||||
|
|
||||||
|
// writeManifest serialises entries into NSYM v3 bytes.
|
||||||
|
func writeManifest(entries []Entry) ([]byte, error) {
|
||||||
|
sorted := make([]Entry, len(entries))
|
||||||
|
copy(sorted, entries)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
left, right := sorted[i].sha1Hex(), sorted[j].sha1Hex()
|
||||||
|
if left != right {
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
return sorted[i].ResRef < sorted[j].ResRef
|
||||||
|
})
|
||||||
|
|
||||||
|
var body, mappings bytes.Buffer
|
||||||
|
seen := make(map[string]uint32, len(sorted))
|
||||||
|
var entryCount, mappingCount uint32
|
||||||
|
for _, entry := range sorted {
|
||||||
|
padded, err := padResRef(entry.ResRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if index, ok := seen[entry.sha1Hex()]; ok {
|
||||||
|
_ = binary.Write(&mappings, binary.LittleEndian, index)
|
||||||
|
mappings.Write(padded)
|
||||||
|
_ = binary.Write(&mappings, binary.LittleEndian, entry.ResType)
|
||||||
|
mappingCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[entry.sha1Hex()] = entryCount
|
||||||
|
entryCount++
|
||||||
|
body.Write(entry.SHA1[:])
|
||||||
|
_ = binary.Write(&body, binary.LittleEndian, entry.Size)
|
||||||
|
body.Write(padded)
|
||||||
|
_ = binary.Write(&body, binary.LittleEndian, entry.ResType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("NSYM")
|
||||||
|
for _, field := range []uint32{manifestVersion, entryCount, mappingCount} {
|
||||||
|
_ = binary.Write(&out, binary.LittleEndian, field)
|
||||||
|
}
|
||||||
|
out.Write(body.Bytes())
|
||||||
|
out.Write(mappings.Bytes())
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readManifest parses NSYM v3 bytes. Mappings are expanded back into entries,
|
||||||
|
// the way upstream's reader does, so a caller sees one entry per resref.
|
||||||
|
func readManifest(data []byte) ([]Entry, error) {
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
magic := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(reader, magic); err != nil || string(magic) != "NSYM" {
|
||||||
|
return nil, fmt.Errorf("not a manifest (invalid magic bytes)")
|
||||||
|
}
|
||||||
|
var version, entryCount, mappingCount uint32
|
||||||
|
for _, field := range []*uint32{&version, &entryCount, &mappingCount} {
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, field); err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated manifest header: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version != manifestVersion {
|
||||||
|
return nil, fmt.Errorf("unsupported manifest version %d", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]Entry, 0, entryCount+mappingCount)
|
||||||
|
for i := uint32(0); i < entryCount; i++ {
|
||||||
|
var entry Entry
|
||||||
|
if _, err := io.ReadFull(reader, entry.SHA1[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated entry %d: %w", i, err)
|
||||||
|
}
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &entry.Size); err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated entry %d: %w", i, err)
|
||||||
|
}
|
||||||
|
resref, restype, err := readResRef(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated entry %d: %w", i, err)
|
||||||
|
}
|
||||||
|
entry.ResRef, entry.ResType = resref, restype
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
for i := uint32(0); i < mappingCount; i++ {
|
||||||
|
var index uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &index); err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated mapping %d: %w", i, err)
|
||||||
|
}
|
||||||
|
if index >= entryCount {
|
||||||
|
return nil, fmt.Errorf("mapping %d references non-existent entry %d", i, index)
|
||||||
|
}
|
||||||
|
resref, restype, err := readResRef(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("truncated mapping %d: %w", i, err)
|
||||||
|
}
|
||||||
|
target := entries[index]
|
||||||
|
entries = append(entries, Entry{SHA1: target.SHA1, Size: target.Size, ResRef: resref, ResType: restype})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readResRef(reader *bytes.Reader) (string, uint16, error) {
|
||||||
|
raw := make([]byte, resRefBytes)
|
||||||
|
if _, err := io.ReadFull(reader, raw); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
var restype uint16
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &restype); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return strings.ToLower(string(bytes.TrimRight(raw, "\x00"))), restype, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func padResRef(resref string) ([]byte, error) {
|
||||||
|
if len(resref) > resRefBytes {
|
||||||
|
return nil, fmt.Errorf("resref %q exceeds %d characters", resref, resRefBytes)
|
||||||
|
}
|
||||||
|
padded := make([]byte, resRefBytes)
|
||||||
|
copy(padded, strings.ToLower(resref))
|
||||||
|
return padded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar is the .json file written next to every manifest. Clients fetch it
|
||||||
|
// (nwn_nwsync_fetch.nim), so the field names and order match upstream.
|
||||||
|
// Upstream omits an integer meta field whose value is 0, hence group_id's
|
||||||
|
// omitempty: 0 means absent, not "group zero".
|
||||||
|
type Sidecar struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
HashTreeDepth int `json:"hash_tree_depth"`
|
||||||
|
ModuleName string `json:"module_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IncludesModuleContents bool `json:"includes_module_contents"`
|
||||||
|
IncludesClientContents bool `json:"includes_client_contents"`
|
||||||
|
TotalFiles int `json:"total_files"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
OnDiskBytes int64 `json:"on_disk_bytes"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
CreatedWith string `json:"created_with"`
|
||||||
|
EmitterVersion string `json:"emitter_version,omitempty"`
|
||||||
|
GroupID int `json:"group_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalSidecar(sidecar Sidecar) ([]byte, error) {
|
||||||
|
body, err := json.MarshalIndent(sidecar, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Upstream terminates the file with CRLF; match it.
|
||||||
|
return append(body, '\r', '\n'), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobPath is the data store path for a blob, hash tree depth 2.
|
||||||
|
func blobPath(root, sha1Hex string) string {
|
||||||
|
return filepath.Join(root, "data", "sha1", sha1Hex[0:2], sha1Hex[2:4], sha1Hex)
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package nwsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func restype(t *testing.T, extension string) uint16 {
|
||||||
|
t.Helper()
|
||||||
|
value, ok := erf.ResourceTypeForExtension(extension)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unknown restype %q", extension)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHak builds a HAK fixture from resref.ext => body pairs.
|
||||||
|
func writeHak(t *testing.T, path string, contents map[string][]byte) {
|
||||||
|
t.Helper()
|
||||||
|
resources := make([]erf.Resource, 0, len(contents))
|
||||||
|
for name, body := range contents {
|
||||||
|
stem, extension, _ := strings.Cut(name, ".")
|
||||||
|
resources = append(resources, erf.Resource{
|
||||||
|
Name: stem,
|
||||||
|
Type: restype(t, extension),
|
||||||
|
Data: body,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := erf.Write(&out, erf.New("HAK", resources)); err != nil {
|
||||||
|
t.Fatalf("write hak: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write hak file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobFramingRoundTrips(t *testing.T) {
|
||||||
|
data := []byte("the quick brown fox jumps over the lazy dog, repeatedly and at length")
|
||||||
|
blob := compressBlob(data)
|
||||||
|
|
||||||
|
header := make([]uint32, 6)
|
||||||
|
if err := binary.Read(bytes.NewReader(blob[:blobHeaderBytes]), binary.LittleEndian, header); err != nil {
|
||||||
|
t.Fatalf("read header: %v", err)
|
||||||
|
}
|
||||||
|
want := []uint32{blobMagic, 3, 2, uint32(len(data)), 1, 0}
|
||||||
|
for i := range want {
|
||||||
|
if header[i] != want[i] {
|
||||||
|
t.Errorf("header field %d = %d, want %d", i, header[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if string(blob[:4]) != "NSYC" {
|
||||||
|
t.Errorf("magic bytes = %q, want NSYC", blob[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := decompressBlob(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decompress: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Errorf("round trip mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestBytesMatchUpstreamLayout(t *testing.T) {
|
||||||
|
shared := sha1.Sum([]byte("shared"))
|
||||||
|
other := sha1.Sum([]byte("other"))
|
||||||
|
// Deliberately out of order, and with two resrefs sharing one sha1: the
|
||||||
|
// second one must become a mapping, not a second entry.
|
||||||
|
entries := []Entry{
|
||||||
|
{SHA1: other, Size: 5, ResRef: "zzz", ResType: 1},
|
||||||
|
{SHA1: shared, Size: 6, ResRef: "bbb", ResType: 2},
|
||||||
|
{SHA1: shared, Size: 6, ResRef: "aaa", ResType: 3},
|
||||||
|
}
|
||||||
|
data, err := writeManifest(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data[:4]) != "NSYM" {
|
||||||
|
t.Fatalf("magic = %q", data[:4])
|
||||||
|
}
|
||||||
|
var version, entryCount, mappingCount uint32
|
||||||
|
reader := bytes.NewReader(data[4:16])
|
||||||
|
for _, field := range []*uint32{&version, &entryCount, &mappingCount} {
|
||||||
|
_ = binary.Read(reader, binary.LittleEndian, field)
|
||||||
|
}
|
||||||
|
if version != 3 || entryCount != 2 || mappingCount != 1 {
|
||||||
|
t.Fatalf("header = version %d, %d entries, %d mappings; want 3/2/1", version, entryCount, mappingCount)
|
||||||
|
}
|
||||||
|
wantSize := 16 + int(entryCount)*(20+4+16+2) + int(mappingCount)*(4+16+2)
|
||||||
|
if len(data) != wantSize {
|
||||||
|
t.Fatalf("manifest is %d bytes, want %d", len(data), wantSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted by sha1 hex then resref, so the shared hash's "aaa" is the entry
|
||||||
|
// and "bbb" is demoted to a mapping.
|
||||||
|
round, err := readManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest: %v", err)
|
||||||
|
}
|
||||||
|
if len(round) != 3 {
|
||||||
|
t.Fatalf("round trip returned %d entries, want 3", len(round))
|
||||||
|
}
|
||||||
|
byResRef := map[string]Entry{}
|
||||||
|
for _, entry := range round {
|
||||||
|
byResRef[entry.ResRef] = entry
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
got, ok := byResRef[entry.ResRef]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resref %q lost in round trip", entry.ResRef)
|
||||||
|
}
|
||||||
|
if got != entry {
|
||||||
|
t.Errorf("resref %q = %+v, want %+v", entry.ResRef, got, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if round[0].ResRef != "aaa" && round[1].ResRef != "aaa" {
|
||||||
|
t.Errorf("entries are not sorted by sha1 then resref: %+v", round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitWritesBlobsAndManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hak := filepath.Join(dir, "sow_test_01.hak")
|
||||||
|
body := []byte("texture bytes")
|
||||||
|
writeHak(t, hak, map[string][]byte{
|
||||||
|
"bloodstain1.tga": body,
|
||||||
|
"copy1.txi": body, // same content, different resref: one blob
|
||||||
|
"script1.nss": []byte("void main() {}"),
|
||||||
|
"debug1.ndb": []byte("debug"),
|
||||||
|
"comment1.gic": []byte("comment"),
|
||||||
|
})
|
||||||
|
|
||||||
|
out := filepath.Join(dir, "out")
|
||||||
|
result, err := Emit(hak, out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("emit: %v", err)
|
||||||
|
}
|
||||||
|
if result.Entries != 2 {
|
||||||
|
t.Errorf("emitted %d entries, want 2 (nss/ndb/gic are always skipped)", result.Entries)
|
||||||
|
}
|
||||||
|
if result.BlobsWritten != 1 {
|
||||||
|
t.Errorf("wrote %d blobs, want 1 (identical content shares a blob)", result.BlobsWritten)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blob is named by the sha1 of the uncompressed bytes, under a depth-2
|
||||||
|
// hash tree, and decompresses back to exactly those bytes.
|
||||||
|
sum := sha1.Sum(body)
|
||||||
|
name := hex.EncodeToString(sum[:])
|
||||||
|
path := filepath.Join(out, "data", "sha1", name[0:2], name[2:4], name)
|
||||||
|
blob, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blob missing at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
got, err := decompressBlob(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decompress blob: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, body) {
|
||||||
|
t.Errorf("blob decompressed to %q, want %q", got, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := readEmitted(t, out, "sow_test_01")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ResType == restype(t, "nss") || entry.ResType == restype(t, "ndb") || entry.ResType == restype(t, "gic") {
|
||||||
|
t.Errorf("skipped restype leaked into the manifest: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sidecar Sidecar
|
||||||
|
body2, err := os.ReadFile(result.ManifestPath + ".json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sidecar missing: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body2, &sidecar); err != nil {
|
||||||
|
t.Fatalf("sidecar json: %v", err)
|
||||||
|
}
|
||||||
|
if sidecar.TotalFiles != 2 || sidecar.HashTreeDepth != 2 || sidecar.Version != 3 {
|
||||||
|
t.Errorf("sidecar = %+v", sidecar)
|
||||||
|
}
|
||||||
|
if sidecar.IncludesModuleContents {
|
||||||
|
t.Error("sidecar claims module contents; a published manifest never has them")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body2), "group_id") {
|
||||||
|
t.Error("per-artifact sidecar should omit group_id (0 means absent)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(out, "latest")); err == nil {
|
||||||
|
t.Error("a latest file was written; there must never be one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEmitted(t *testing.T, dir, name string) []Entry {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name+".nsym"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read emitted manifest: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := readManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse emitted manifest: %v", err)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitFailsClosedOnOversizeResource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hak := filepath.Join(dir, "big.hak")
|
||||||
|
writeHak(t, hak, map[string][]byte{"huge1.tga": make([]byte, fileSizeLimit+1)})
|
||||||
|
|
||||||
|
if _, err := Emit(hak, filepath.Join(dir, "out")); err == nil {
|
||||||
|
t.Fatal("emit accepted a resource over the 15 MB limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitLooseFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tlk := filepath.Join(dir, "sow_tlk.tlk")
|
||||||
|
if err := os.WriteFile(tlk, []byte("TLK V3.0 payload"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, "out")
|
||||||
|
result, err := Emit(tlk, out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("emit tlk: %v", err)
|
||||||
|
}
|
||||||
|
entries := readEmitted(t, out, "sow_tlk")
|
||||||
|
if len(entries) != 1 || entries[0].ResRef != "sow_tlk" || entries[0].ResType != restype(t, "tlk") {
|
||||||
|
t.Fatalf("tlk emitted as %+v", entries)
|
||||||
|
}
|
||||||
|
if result.BlobsWritten != 1 {
|
||||||
|
t.Errorf("wrote %d blobs, want 1", result.BlobsWritten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitFixture emits two haks that share a resref, so the merge rule is
|
||||||
|
// observable: "top" holds the winning body, "assets" the shadowed one.
|
||||||
|
func emitFixture(t *testing.T) (string, []byte, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
topBody := []byte("2da from sow_top")
|
||||||
|
assetBody := []byte("2da from the asset hak")
|
||||||
|
writeHak(t, filepath.Join(dir, "sow_top.hak"), map[string][]byte{"appearance.2da": topBody})
|
||||||
|
writeHak(t, filepath.Join(dir, "sow_core_01.hak"), map[string][]byte{
|
||||||
|
"appearance.2da": assetBody,
|
||||||
|
"bloodstain1.tga": []byte("blood"),
|
||||||
|
})
|
||||||
|
out := filepath.Join(dir, "out")
|
||||||
|
for _, name := range []string{"sow_top", "sow_core_01"} {
|
||||||
|
if _, err := Emit(filepath.Join(dir, name+".hak"), out); err != nil {
|
||||||
|
t.Fatalf("emit %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, topBody, assetBody
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleShadowsByOrder(t *testing.T) {
|
||||||
|
entriesDir, topBody, assetBody := emitFixture(t)
|
||||||
|
out := t.TempDir()
|
||||||
|
|
||||||
|
result, err := Assemble(AssembleOptions{
|
||||||
|
Order: []string{"sow_top", "sow_core_01"},
|
||||||
|
EntriesDir: entriesDir,
|
||||||
|
OutDir: out,
|
||||||
|
GroupID: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assemble: %v", err)
|
||||||
|
}
|
||||||
|
if result.Entries != 2 {
|
||||||
|
t.Fatalf("merged %d entries, want 2 (appearance.2da is shadowed, not duplicated)", result.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(result.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read merged manifest: %v", err)
|
||||||
|
}
|
||||||
|
merged := sha1.Sum(data)
|
||||||
|
if hex.EncodeToString(merged[:]) != result.SHA1 || filepath.Base(result.ManifestPath) != result.SHA1 {
|
||||||
|
t.Errorf("manifest is not named by its own sha1: %s", result.ManifestPath)
|
||||||
|
}
|
||||||
|
entries, err := readManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse merged manifest: %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ResRef != "appearance" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.SHA1 != sha1.Sum(topBody) {
|
||||||
|
t.Errorf("appearance.2da resolved to the wrong hak; want the earliest in --order")
|
||||||
|
}
|
||||||
|
if entry.SHA1 == sha1.Sum(assetBody) {
|
||||||
|
t.Error("appearance.2da resolved to the shadowed hak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecar := Sidecar{}
|
||||||
|
body, err := os.ReadFile(result.ManifestPath + ".json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merged sidecar missing: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &sidecar); err != nil {
|
||||||
|
t.Fatalf("merged sidecar json: %v", err)
|
||||||
|
}
|
||||||
|
if sidecar.GroupID != 2 {
|
||||||
|
t.Errorf("group_id = %d, want 2 (testing)", sidecar.GroupID)
|
||||||
|
}
|
||||||
|
if sidecar.SHA1 != result.SHA1 {
|
||||||
|
t.Errorf("sidecar sha1 = %s, want %s", sidecar.SHA1, result.SHA1)
|
||||||
|
}
|
||||||
|
if sidecar.TotalFiles != 2 {
|
||||||
|
t.Errorf("total_files = %d, want 2", sidecar.TotalFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReversedOrderPicksTheOtherHak(t *testing.T) {
|
||||||
|
entriesDir, topBody, assetBody := emitFixture(t)
|
||||||
|
out := t.TempDir()
|
||||||
|
|
||||||
|
result, err := Assemble(AssembleOptions{
|
||||||
|
Order: []string{"sow_core_01", "sow_top"},
|
||||||
|
EntriesDir: entriesDir,
|
||||||
|
OutDir: out,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assemble: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(result.ManifestPath)
|
||||||
|
entries, err := readManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse merged manifest: %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ResRef == "appearance" && entry.SHA1 != sha1.Sum(assetBody) {
|
||||||
|
t.Errorf("appearance.2da did not follow --order; still resolves to %x", entry.SHA1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = topBody
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleRefusesMismatchedEmitterVersions(t *testing.T) {
|
||||||
|
entriesDir, _, _ := emitFixture(t)
|
||||||
|
|
||||||
|
path := filepath.Join(entriesDir, "sow_core_01.nsym.json")
|
||||||
|
var sidecar Sidecar
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &sidecar); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sidecar.EmitterVersion = "0"
|
||||||
|
patched, err := marshalSidecar(sidecar)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, patched, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Assemble(AssembleOptions{
|
||||||
|
Order: []string{"sow_top", "sow_core_01"},
|
||||||
|
EntriesDir: entriesDir,
|
||||||
|
OutDir: t.TempDir(),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "emitter version mismatch") {
|
||||||
|
t.Fatalf("assemble merged across emitter versions: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitHonoursSourceDateEpoch(t *testing.T) {
|
||||||
|
t.Setenv("SOURCE_DATE_EPOCH", "1700000000")
|
||||||
|
dir := t.TempDir()
|
||||||
|
hak := filepath.Join(dir, "pinned.hak")
|
||||||
|
writeHak(t, hak, map[string][]byte{"one1.tga": []byte("body")})
|
||||||
|
result, err := Emit(hak, filepath.Join(dir, "out"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("emit: %v", err)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(result.ManifestPath + ".json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sidecar Sidecar
|
||||||
|
if err := json.Unmarshal(body, &sidecar); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sidecar.Created != 1700000000 {
|
||||||
|
t.Errorf("created = %d, want the pinned SOURCE_DATE_EPOCH", sidecar.Created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitRejectsAModule(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sow.mod")
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := erf.Write(&out, erf.New("MOD", []erf.Resource{
|
||||||
|
{Name: "module", Type: restype(t, "ifo"), Data: []byte("ifo"), Size: 3},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Emit(path, filepath.Join(dir, "out")); err == nil {
|
||||||
|
t.Fatal("emit accepted a .mod; a manifest never carries module contents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleFailsClosedOnMissingIndex(t *testing.T) {
|
||||||
|
entriesDir, _, _ := emitFixture(t)
|
||||||
|
_, err := Assemble(AssembleOptions{
|
||||||
|
Order: []string{"sow_top", "sow_never_published"},
|
||||||
|
EntriesDir: entriesDir,
|
||||||
|
OutDir: t.TempDir(),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sow_never_published") {
|
||||||
|
t.Fatalf("assemble did not fail closed and name the missing artifact: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUsageErrors(t *testing.T) {
|
||||||
|
cases := [][]string{
|
||||||
|
nil,
|
||||||
|
{"nope"},
|
||||||
|
{"emit"},
|
||||||
|
{"emit", "artifact.hak"},
|
||||||
|
{"assemble", "--entries", "x", "--out", "y"},
|
||||||
|
{"assemble", "--order", "a", "--out", "y"},
|
||||||
|
{"assemble", "--order", "a", "--entries", "x"},
|
||||||
|
}
|
||||||
|
for _, args := range cases {
|
||||||
|
var out, errw bytes.Buffer
|
||||||
|
if code := Run(args, &out, &errw); code != exitUsage {
|
||||||
|
t.Errorf("Run(%v) exit=%d, want %d", args, code, exitUsage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package nwsync publishes NWSync repository data: blobs and a per-artifact
|
||||||
|
// NSYM manifest at each artifact's birth (emit), and one merged manifest at
|
||||||
|
// module release (assemble).
|
||||||
|
//
|
||||||
|
// The split exists because upstream nwn_nwsync_write wants every hak, the TLK
|
||||||
|
// and the module present in one run on one disk, which our build hosts cannot
|
||||||
|
// hold. Upstream stays the conformance oracle: manifests compare byte for
|
||||||
|
// byte, blobs compare after decompression.
|
||||||
|
package nwsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitUsage = 64
|
||||||
|
exitInternal = 70
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes an nwsync subcommand. args[0] is the subcommand (emit|assemble);
|
||||||
|
// returns the process exit code.
|
||||||
|
func Run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printRunUsage(stderr)
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "emit":
|
||||||
|
return runEmit(args[1:], stdout, stderr)
|
||||||
|
case "assemble":
|
||||||
|
return runAssemble(args[1:], stdout, stderr)
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
printRunUsage(stdout)
|
||||||
|
return exitOK
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(stderr, "nwsync: unknown subcommand %q\n\n", args[0])
|
||||||
|
printRunUsage(stderr)
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRunUsage(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `usage:
|
||||||
|
nwsync emit <artifact> --out DIR
|
||||||
|
nwsync assemble --order NAMES --entries DIR --out DIR [--group-id N]
|
||||||
|
|
||||||
|
emit explodes one .hak/.erf or one loose file (the TLK) into NWSync blobs plus
|
||||||
|
a NSYM manifest covering only that artifact. assemble merges those per-artifact
|
||||||
|
manifests into one, reading no bulk data.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEmit(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("emit", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
out := fs.String("out", "", "output directory (blobs plus the per-artifact manifest)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
fmt.Fprintf(stderr, "nwsync emit: exactly one artifact is required\n")
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if *out == "" {
|
||||||
|
fmt.Fprintf(stderr, "nwsync emit: --out is required\n")
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Emit(fs.Arg(0), *out)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "nwsync emit: %v\n", err)
|
||||||
|
return exitInternal
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "emitted %s: %d resources, %d new blobs, manifest %s\n",
|
||||||
|
result.Name, result.Entries, result.BlobsWritten, result.ManifestPath)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAssemble(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("assemble", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
order := fs.String("order", "", "comma-separated artifact names, highest priority first")
|
||||||
|
entries := fs.String("entries", "", "directory holding the per-artifact .nsym files")
|
||||||
|
out := fs.String("out", "", "repository root; the manifest lands in <out>/manifests")
|
||||||
|
groupID := fs.Int("group-id", 0, "NWSync group id (1 = current, 2 = testing; 0 omits it)")
|
||||||
|
moduleName := fs.String("module-name", "", "module name recorded in the sidecar")
|
||||||
|
description := fs.String("description", "", "description recorded in the sidecar")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *order == "":
|
||||||
|
fmt.Fprintf(stderr, "nwsync assemble: --order is required\n")
|
||||||
|
return exitUsage
|
||||||
|
case *entries == "":
|
||||||
|
fmt.Fprintf(stderr, "nwsync assemble: --entries is required\n")
|
||||||
|
return exitUsage
|
||||||
|
case *out == "":
|
||||||
|
fmt.Fprintf(stderr, "nwsync assemble: --out is required\n")
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, 8)
|
||||||
|
for _, name := range strings.Split(*order, ",") {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Assemble(AssembleOptions{
|
||||||
|
Order: names,
|
||||||
|
EntriesDir: *entries,
|
||||||
|
OutDir: *out,
|
||||||
|
GroupID: *groupID,
|
||||||
|
ModuleName: *moduleName,
|
||||||
|
Description: *description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "nwsync assemble: %v\n", err)
|
||||||
|
return exitInternal
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "assembled %s: %d resources from %d artifacts\n",
|
||||||
|
result.SHA1, result.Entries, len(names))
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ bin=bin
|
|||||||
|
|
||||||
# Keep in sync with internal/dispatch.Registry (Wired flag).
|
# Keep in sync with internal/dispatch.Registry (Wired flag).
|
||||||
unwired=()
|
unwired=()
|
||||||
wired=(assets depot hak module topdata wiki)
|
wired=(assets depot hak module nwsync topdata wiki)
|
||||||
|
|
||||||
exit_of() { set +e; "$@" >/dev/null 2>&1; local c=$?; set -e; echo "${c}"; }
|
exit_of() { set +e; "$@" >/dev/null 2>&1; local c=$?; set -e; echo "${c}"; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user