Files
sow-tools/internal/nwsync/assemble.go
T
archvillainetteandClaude Opus 5 f2a0d6a8d1
ci / ci (pull_request) Successful in 3m21s
feat(nwsync): upload sink, key-addressed CLI, fail-closed publication marker
Resolves the build half of sow-tools#60 and closes the CLI gap sow-tools#53's
sweep found: PR #71 shipped #53's original surface, not the one #56, #62 and
#65 settled.

- `depot.KeyStore` (`ProbeKey`/`PutReader`/`GetKey`) addresses the zone by
  object key instead of by depot sha, reusing the existing IPv4-pinned
  transport, retry and tri-state probe. The sha-addressed `Backend` now rides
  on it; no new HTTP client.
- `nwsync` gains a sink: the zone by default, a local tree with `--out DIR` as
  the conformance path. Blobs upload as they are produced, the index lands
  last, and a blob already in the zone is skipped without paying for
  compression.
- CLI is now `emit [--as NAME] [--out DIR] <artifact-key> <file>` and
  `assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...`.
  Indexes live beside their artifact with the extension replaced, derived in
  one place. Flags may follow positionals, which cost a run during sow-tools#59.
- Fail-closed: an artifact key whose digest does not match the file is refused,
  and `assemble` refuses an artifact with no index rather than publishing a
  manifest missing a hak.

Conformance re-checked through the new CLI against upstream nwn_nwsync_write
2.1.2 on sow_vfxs_01.hak: the manifest is still byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:16:44 +02:00

129 lines
4.2 KiB
Go

package nwsync
import (
"crypto/sha1"
"encoding/json"
"fmt"
"path"
)
// AssembleOptions describes one merged manifest.
type AssembleOptions struct {
// ArtifactKeys are the depot keys of the artifacts to merge, in
// Mod_HakList order — highest priority first. Each one's index is read
// from the key beside it.
ArtifactKeys []string
TLKKey string // the TLK's key, if the manifest carries one
OutDir string // write locally instead of uploading — the conformance path
GroupID int // 1 = current, 2 = testing; 0 means absent
ModuleName string
Description string
Sink sink // test seam; nil means OutDir or the zone
}
// 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.ArtifactKeys) == 0 {
return AssembleResult{}, fmt.Errorf("assemble: no artifact keys given")
}
target, err := openSink(options.OutDir, options.Sink)
if err != nil {
return AssembleResult{}, err
}
// The TLK carries no precedence — it is not a hak and shadows nothing —
// so it merges last, after every hak has had its say.
keys := append([]string{}, options.ArtifactKeys...)
if options.TLKKey != "" {
keys = append(keys, options.TLKKey)
}
merged := make([]Entry, 0, 1024)
winner := make(map[Identity]bool, 1024)
var onDiskBytes int64
for _, artifactKey := range keys {
key, err := resolveIndexKey(artifactKey, options.OutDir)
if err != nil {
return AssembleResult{}, err
}
data, sidecarBody, err := target.getIndex(key)
if err != nil {
// An artifact with no index is an artifact whose emit never
// finished. Publishing a manifest without it would ship a release
// missing a hak, so this fails closed.
return AssembleResult{}, fmt.Errorf("assemble: no index for %s: %w", artifactKey, err)
}
entries, err := readManifest(data)
if err != nil {
return AssembleResult{}, fmt.Errorf("%s: %w", target.describe(key), err)
}
sidecar, err := parseSidecar(target.describe(key), sidecarBody)
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",
artifactKey, 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))
manifestKey := path.Join("manifests", sha1Hex)
sidecar := Sidecar{
ModuleName: options.ModuleName,
Description: options.Description,
GroupID: options.GroupID,
}
if err := putManifestPair(target, manifestKey, data, merged, onDiskBytes, sidecar); err != nil {
return AssembleResult{}, err
}
return AssembleResult{SHA1: sha1Hex, ManifestPath: target.describe(manifestKey), Entries: len(merged)}, nil
}
func parseSidecar(where string, data []byte) (Sidecar, error) {
var sidecar Sidecar
if err := json.Unmarshal(data, &sidecar); err != nil {
return Sidecar{}, fmt.Errorf("%s: %w", where, err)
}
return sidecar, nil
}