Files
sow-tools/internal/pipeline/build.go
T
archvillainette f1fd03ee83
test-image / build-image (push) Successful in 47s
test / test (push) Successful in 1m27s
build-binaries / build-binaries (push) Successful in 2m14s
build-image / publish (push) Successful in 13s
pipeline: reject resref collisions when chunking; document erf post-write hashing (#8)
## What

Two correctness fixes surfaced in review of the direct-depot HAK artifact work.

### Reject resref+type collisions in `chunksFromManifest`

A manifest can name two distinct source paths that collapse to the same
`resref+type` (e.g. `creature/foo.tga` and `placeable/foo.tga` — resref is the
lowercase basename minus extension). The ERF writer keys resources on
`Name:Type`, so the second silently shadowed the first: an asset would vanish
from the packed HAK with no error.

`chunksFromManifest` now runs `ensureUniqueChunkResources` per chunk and fails
the build on a duplicate. This guards **both** build paths — the legacy
`--source-manifest` flow and the direct content-addressed flow
(`chunksFromSourceManifest` → `chunksFromManifest`).

### Document erf post-write hash coupling

`writeResourceData` streams the source into the output while hashing, so
size/SHA mismatches are only detected *after* the bytes are written. A non-nil
return therefore means the writer holds partial, unverified output and the
caller must discard it. Added a comment making that contract explicit;
`writeHAKArchive` already honours it (writes to a temp file, removes on any
Write error, never renames a bad archive into place).

## Tests

- `TestChunksFromManifestRejectsResrefCollision`: collision → error, distinct
  resrefs → clean. Asserts only error presence/absence — silent asset loss is
  the contract, not any specific wording.
- `go vet ./internal/erf/ ./internal/pipeline/` clean.
- `go test ./internal/erf/ ./internal/pipeline/` green.

## Follow-up

A new crucible release must be cut after this merges so the guard ships in the
binary `sow-assets-manifest` pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #8
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-06-20 07:53:04 +00:00

1740 lines
49 KiB
Go

package pipeline
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator"
)
type BuildResult struct {
ModulePath string
HAKPaths []string
Manifest string
AutogenManifestPaths []string
CreditsArtifactPaths []string
CreditsSummary CreditsRefreshSummary
HAKSummary HAKArchiveSummary
Resources int
HAKAssets int
TopPackageHAK string
TopPackageTLK string
TopPackageFiles int
}
type CreditsRefreshSummary struct {
ScannedDirs []string
TrackCount int
ArtifactPaths []string
GeneratedPaths []string
InventoryPath string
ChangedFiles int
Mappings []MusicFileMapping
}
type MusicFileMapping struct {
Source string
Output string
}
type HAKArchiveSummary struct {
Total int
Reused int
Written int
Actions []HAKArchiveAction
}
type HAKArchiveAction struct {
Name string
AssetCount int
Reused bool
OutputPath string
}
type BuildManifest struct {
ModuleHAKs []string `json:"module_haks"`
HAKs []BuildManifestHAK `json:"haks"`
}
type BuildManifestHAK struct {
Name string `json:"name"`
Group string `json:"group"`
Priority int `json:"priority"`
MaxBytes int64 `json:"max_bytes"`
SizeBytes int64 `json:"size_bytes"`
Optional bool `json:"optional,omitempty"`
Assets []string `json:"assets"`
ContentHash string `json:"content_hash,omitempty"`
}
type assetResource struct {
Rel string
Resource erf.Resource
Size int64
CreatedAt time.Time
ContentID string
}
type lfsAssetInfo struct {
OID string
Size int64
ObjectPath string
}
type hakChunk struct {
Config project.HAKConfig
Index int
Name string
Assets []assetResource
Size int64
}
type ProgressFunc func(string)
type BuildHAKOptions struct {
Progress ProgressFunc
ArchiveNames []string
SourceManifestPath string
ContentAddressedRoot string
SkipMusic bool
MusicDatasetIDs []string
}
func Build(p *project.Project) (BuildResult, error) {
var topPackage topdata.PackageResult
var err error
if p.HasTopData() {
topPackage, err = topdata.BuildAndPackage(p, nil)
if err != nil {
return BuildResult{}, err
}
}
moduleResult, err := BuildModule(p)
if err != nil {
return BuildResult{}, err
}
hakResult, err := BuildHAKs(p)
if err != nil {
return BuildResult{}, err
}
moduleResult.HAKPaths = hakResult.HAKPaths
moduleResult.Manifest = hakResult.Manifest
moduleResult.HAKAssets = hakResult.HAKAssets
moduleResult.TopPackageHAK = topPackage.OutputHAKPath
moduleResult.TopPackageTLK = topPackage.OutputTLKPath
moduleResult.TopPackageFiles = topPackage.HAKResources
return moduleResult, nil
}
func BuildModule(p *project.Project) (BuildResult, error) {
return buildModule(p, nil)
}
func BuildModuleWithProgress(p *project.Project, progress ProgressFunc) (BuildResult, error) {
return buildModule(p, progress)
}
func buildModule(p *project.Project, progress ProgressFunc) (BuildResult, error) {
progressf(progress, "Validating project...")
if err := validateForBuild(p); err != nil {
return BuildResult{}, err
}
progressf(progress, "Resolving module HAK order...")
moduleHakOrder, err := plannedModuleHAKOrder(p)
if err != nil {
return BuildResult{}, err
}
progressf(progress, "Collecting module resources...")
moduleResources, err := collectModuleResources(p, moduleHakOrder)
if err != nil {
return BuildResult{}, err
}
progressf(progress, "Writing module archive...")
outputPath := p.ModuleArchivePath()
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create module output dir: %w", err)
}
output, err := os.Create(outputPath)
if err != nil {
return BuildResult{}, fmt.Errorf("create module archive: %w", err)
}
defer output.Close()
if err := erf.Write(output, erf.New("MOD ", moduleResources)); err != nil {
return BuildResult{}, fmt.Errorf("write module archive: %w", err)
}
return BuildResult{
ModulePath: outputPath,
Resources: len(moduleResources),
}, nil
}
func BuildHAKs(p *project.Project) (BuildResult, error) {
return buildHAKs(p, nil)
}
func BuildHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResult, error) {
return buildHAKs(p, progress)
}
func BuildHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) {
return planOrBuildHAKs(p, opts.Progress, true, opts)
}
func PlanHAKs(p *project.Project) (BuildResult, error) {
return planHAKs(p, nil)
}
func PlanHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResult, error) {
return planHAKs(p, progress)
}
func PlanHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) {
return planOrBuildHAKs(p, opts.Progress, false, opts)
}
func buildHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
return planOrBuildHAKs(p, progress, true, BuildHAKOptions{})
}
func planHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
return planOrBuildHAKs(p, progress, false, BuildHAKOptions{})
}
func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, opts BuildHAKOptions) (result BuildResult, err error) {
preserveExistingHAKs := p.EffectiveConfig().Build.KeepExistingHAKs || envBool("SOW_BUILD_HAKS_KEEP_EXISTING")
progressf(progress, "Validating project...")
if err := validateForBuild(p); err != nil {
return BuildResult{}, err
}
directManifest, err := loadDirectSourceManifest(opts.SourceManifestPath)
if err != nil {
return BuildResult{}, err
}
if directManifest != nil {
return buildDirectHAKs(p, progress, writeArchives, directManifest, opts)
}
progressf(progress, "Collecting asset resources...")
allowedAssets, sourceManifest, err := loadSourceManifestAssetSet(opts.SourceManifestPath)
if err != nil {
return BuildResult{}, err
}
musicAssets := &preparedMusicAssets{SkipSourceRel: map[string]struct{}{}}
if !opts.SkipMusic {
musicAssets, err = prepareMusicAssetsWithOptions(p, musicPrepareOptions{
datasetIDs: opts.MusicDatasetIDs,
write: writeArchives,
})
if err != nil {
return BuildResult{}, err
}
}
defer func() {
if cleanupErr := cleanupPreparedMusicAssets(musicAssets); cleanupErr != nil && err == nil {
err = cleanupErr
}
}()
generated2DAAssets, err := topdata.BuildGenerated2DAAssets(p, progress)
if err != nil {
return BuildResult{}, err
}
assetResources, err := collectAssetResources(p, false, allowedAssets, musicAssets, generated2DAAssets)
if err != nil {
return BuildResult{}, err
}
autogenManifests, err := topdata.ProduceAutogenManifests(p)
if err != nil {
return BuildResult{}, err
}
var previousManifest *BuildManifest
if sourceManifest == nil || writeArchives {
previousManifest, err = loadPreviousBuildManifest(p.HAKManifestPath())
if err != nil {
return BuildResult{}, err
}
}
result = BuildResult{HAKAssets: len(assetResources)}
result.CreditsArtifactPaths = append(result.CreditsArtifactPaths, musicAssets.Artifacts...)
result.CreditsSummary = musicAssets.Summary
if err := writeAutogenManifestOutputs(progress, autogenManifests); err != nil {
return BuildResult{}, err
}
for _, manifest := range autogenManifests {
result.AutogenManifestPaths = append(result.AutogenManifestPaths, manifest.OutputPath)
}
if len(assetResources) == 0 {
progressf(progress, "Cleaning previous generated HAKs...")
if err := cleanupGeneratedHAKs(p); err != nil {
return BuildResult{}, err
}
return result, nil
}
progressf(progress, "Planning HAK chunks...")
var chunks []hakChunk
if sourceManifest != nil {
chunks, err = chunksFromManifest(assetResources, sourceManifest.HAKs)
} else {
chunks, err = planHAKChunksWithPrevious(p, assetResources, previousManifest)
}
if err != nil {
return BuildResult{}, err
}
chunks, err = filterHAKChunksByName(chunks, opts.ArchiveNames)
if err != nil {
return BuildResult{}, err
}
if writeArchives {
chunks, err = hydrateHAKChunks(p, chunks)
if err != nil {
return BuildResult{}, err
}
}
progressf(progress, "Resolving module HAK order...")
moduleHakOrder := []string{}
if sourceManifest != nil {
moduleHakOrder = append(moduleHakOrder, sourceManifest.ModuleHAKs...)
} else {
moduleHakOrder, err = resolveModuleHAKOrder(p, chunks)
if err != nil {
return BuildResult{}, err
}
}
manifest := BuildManifest{
ModuleHAKs: moduleHakOrder,
HAKs: make([]BuildManifestHAK, 0, len(chunks)),
}
currentNames := make(map[string]struct{}, len(chunks))
for _, chunk := range chunks {
manifestEntry := buildManifestEntry(chunk)
currentNames[chunk.Name] = struct{}{}
manifest.HAKs = append(manifest.HAKs, manifestEntry)
}
manifestPath := p.HAKManifestPath()
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return BuildResult{}, fmt.Errorf("marshal hak manifest: %w", err)
}
manifestBytes = append(manifestBytes, '\n')
if !writeArchives {
progressf(progress, "Cleaning previous generated HAKs...")
if err := cleanupGeneratedHAKs(p); err != nil {
return BuildResult{}, err
}
progressf(progress, "Writing HAK manifest...")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak manifest dir: %w", err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
result.Manifest = manifestPath
return result, nil
}
progressf(progress, "Reusing unchanged generated HAKs where possible...")
result.HAKSummary.Total = len(chunks)
result.HAKPaths = make([]string, 0, len(chunks))
for index, chunk := range chunks {
hakPath := p.HAKArchivePath(chunk.Name)
manifestEntry := manifest.HAKs[index]
reused, err := reuseExistingHAK(previousManifest, manifestEntry, hakPath)
if err != nil {
return BuildResult{}, err
}
if reused {
result.HAKSummary.Reused++
progressf(progress, fmt.Sprintf("Reusing HAK %d/%d: %s (%d assets)", index+1, len(chunks), chunk.Name, len(chunk.Assets)))
} else {
result.HAKSummary.Written++
progressf(progress, fmt.Sprintf("Writing HAK %d/%d: %s (%d assets)", index+1, len(chunks), chunk.Name, len(chunk.Assets)))
if err := os.MkdirAll(filepath.Dir(hakPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak archive dir: %w", err)
}
if err := writeHAKArchive(hakPath, resourceSlice(chunk.Assets)); err != nil {
return BuildResult{}, err
}
}
result.HAKSummary.Actions = append(result.HAKSummary.Actions, HAKArchiveAction{
Name: chunk.Name,
AssetCount: len(chunk.Assets),
Reused: reused,
OutputPath: hakPath,
})
result.HAKPaths = append(result.HAKPaths, hakPath)
}
progressf(progress, "Cleaning stale generated HAKs...")
if !preserveExistingHAKs {
if err := cleanupStaleGeneratedHAKs(p, currentNames); err != nil {
return BuildResult{}, err
}
}
progressf(progress, "Writing HAK manifest...")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak manifest dir: %w", err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
result.Manifest = manifestPath
return result, nil
}
// buildDirectHAKs packages HAKs directly from the content-addressed source blob
// cache. It skips music preparation, generated 2da discovery, git creation-time
// lookups, LFS discovery, and any materialized asset tree scan.
func buildDirectHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, manifest *SourceBuildManifest, opts BuildHAKOptions) (BuildResult, error) {
progressf(progress, "Collecting asset resources...")
assetResources, err := collectDirectSourceResources(manifest, opts.ContentAddressedRoot)
if err != nil {
return BuildResult{}, err
}
result := BuildResult{HAKAssets: len(assetResources)}
progressf(progress, "Planning HAK chunks...")
chunks, err := chunksFromSourceManifest(assetResources, manifest.HAKs)
if err != nil {
return BuildResult{}, err
}
chunks, err = filterHAKChunksByName(chunks, opts.ArchiveNames)
if err != nil {
return BuildResult{}, err
}
buildManifest := BuildManifest{
ModuleHAKs: append([]string(nil), manifest.ModuleHAKs...),
HAKs: make([]BuildManifestHAK, 0, len(chunks)),
}
for _, chunk := range chunks {
buildManifest.HAKs = append(buildManifest.HAKs, buildManifestEntry(chunk))
}
manifestPath := p.HAKManifestPath()
manifestBytes, err := json.MarshalIndent(buildManifest, "", " ")
if err != nil {
return BuildResult{}, fmt.Errorf("marshal hak manifest: %w", err)
}
manifestBytes = append(manifestBytes, '\n')
if writeArchives {
progressf(progress, "Writing HAK archives...")
result.HAKSummary.Total = len(chunks)
result.HAKPaths = make([]string, 0, len(chunks))
for index, chunk := range chunks {
hakPath := p.HAKArchivePath(chunk.Name)
progressf(progress, fmt.Sprintf("Writing HAK %d/%d: %s (%d assets)", index+1, len(chunks), chunk.Name, len(chunk.Assets)))
if err := os.MkdirAll(filepath.Dir(hakPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak archive dir: %w", err)
}
if err := writeHAKArchive(hakPath, resourceSlice(chunk.Assets)); err != nil {
return BuildResult{}, err
}
result.HAKSummary.Written++
result.HAKSummary.Actions = append(result.HAKSummary.Actions, HAKArchiveAction{
Name: chunk.Name,
AssetCount: len(chunk.Assets),
OutputPath: hakPath,
})
result.HAKPaths = append(result.HAKPaths, hakPath)
}
}
progressf(progress, "Writing HAK manifest...")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak manifest dir: %w", err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
result.Manifest = manifestPath
return result, nil
}
// writeHAKArchive publishes a completed HAK atomically, so a failed write never
// leaves a partial archive at the final path.
func writeHAKArchive(hakPath string, resources []erf.Resource) error {
tmpPath := hakPath + ".tmp"
if err := os.Remove(tmpPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove stale temporary hak %s: %w", tmpPath, err)
}
output, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("create temporary hak archive %s: %w", tmpPath, err)
}
if err := erf.Write(output, erf.New("HAK ", resources)); err != nil {
output.Close()
os.Remove(tmpPath)
return fmt.Errorf("write hak archive: %w", err)
}
if err := output.Sync(); err != nil {
output.Close()
os.Remove(tmpPath)
return fmt.Errorf("sync hak archive: %w", err)
}
if err := output.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close hak archive: %w", err)
}
if err := os.Rename(tmpPath, hakPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("publish hak archive %s: %w", hakPath, err)
}
return nil
}
func writeAutogenManifestOutputs(progress ProgressFunc, manifests []topdata.ProducedAutogenManifest) error {
if len(manifests) == 0 {
return nil
}
for _, manifest := range manifests {
progressf(progress, fmt.Sprintf("Writing autogen manifest %s...", filepath.Base(manifest.OutputPath)))
if err := os.MkdirAll(filepath.Dir(manifest.OutputPath), 0o755); err != nil {
return fmt.Errorf("create autogen manifest dir: %w", err)
}
if err := os.WriteFile(manifest.OutputPath, manifest.Payload, 0o644); err != nil {
return fmt.Errorf("write autogen manifest %s: %w", manifest.AssetName, err)
}
}
return nil
}
func filterHAKChunksByName(chunks []hakChunk, archiveNames []string) ([]hakChunk, error) {
if len(archiveNames) == 0 {
return chunks, nil
}
wanted := make(map[string]struct{}, len(archiveNames))
for _, name := range archiveNames {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
continue
}
wanted[trimmed] = struct{}{}
}
if len(wanted) == 0 {
return chunks, nil
}
filtered := make([]hakChunk, 0, len(wanted))
found := make(map[string]struct{}, len(wanted))
for _, chunk := range chunks {
key := strings.ToLower(strings.TrimSpace(chunk.Name))
if _, ok := wanted[key]; !ok {
continue
}
filtered = append(filtered, chunk)
found[key] = struct{}{}
}
var missing []string
for _, name := range archiveNames {
key := strings.TrimSpace(strings.ToLower(name))
if key == "" {
continue
}
if _, ok := found[key]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("unknown hak archive(s): %s", strings.Join(missing, ", "))
}
return filtered, nil
}
func hydrateHAKChunks(p *project.Project, chunks []hakChunk) ([]hakChunk, error) {
if len(chunks) == 0 {
return chunks, nil
}
lfsAssets, err := collectLFSAssetInfo(p)
if err != nil {
return nil, err
}
hydrated := make([]hakChunk, 0, len(chunks))
for _, chunk := range chunks {
nextChunk := chunk
nextChunk.Assets = make([]assetResource, 0, len(chunk.Assets))
for _, asset := range chunk.Assets {
if !strings.HasPrefix(asset.ContentID, "lfs:") {
nextChunk.Assets = append(nextChunk.Assets, asset)
continue
}
info, ok := lfsAssets[filepath.ToSlash(asset.Rel)]
if !ok {
return nil, fmt.Errorf("missing LFS metadata for %s", asset.Rel)
}
resource, err := lfsPathResource(filepath.Join(p.AssetsDir(), filepath.FromSlash(asset.Rel)), info, true)
if err != nil {
return nil, err
}
asset.Resource = resource
nextChunk.Assets = append(nextChunk.Assets, asset)
}
hydrated = append(hydrated, nextChunk)
}
return hydrated, nil
}
func progressf(progress ProgressFunc, message string) {
if progress != nil {
progress(message)
}
}
func envBool(name string) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
switch value {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf.Resource, error) {
var moduleResources []erf.Resource
for _, rel := range p.Inventory.SourceFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
resource, err := resourceFromJSON(abs, moduleHakOrder)
if err != nil {
return nil, err
}
moduleResources = append(moduleResources, resource)
}
for _, rel := range p.Inventory.ScriptFiles {
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
resource, err := rawResource(abs)
if err != nil {
return nil, err
}
moduleResources = append(moduleResources, resource)
}
sortResources(moduleResources)
return moduleResources, nil
}
func collectAssetResources(p *project.Project, requireContent bool, allowed map[string]struct{}, musicAssets *preparedMusicAssets, generated2DAAssets []topdata.Generated2DAAsset) ([]assetResource, error) {
var hakResources []assetResource
assetCreatedAt, err := collectGitAssetCreationTimes(p)
if err != nil {
return nil, err
}
lfsAssets, err := collectLFSAssetInfo(p)
if err != nil {
return nil, err
}
assetsRelPath, err := filepath.Rel(p.Root, p.AssetsDir())
if err != nil {
return nil, fmt.Errorf("resolve assets dir relative path: %w", err)
}
assetsRelPath = filepath.ToSlash(assetsRelPath)
for _, rel := range p.Inventory.AssetFiles {
if musicAssets != nil {
if _, skip := musicAssets.SkipSourceRel[filepath.ToSlash(rel)]; skip {
continue
}
}
if len(allowed) > 0 {
if _, ok := allowed[filepath.ToSlash(rel)]; !ok {
continue
}
}
abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
resourceInfo, err := assetResourceFromPath(abs, filepath.ToSlash(rel), lfsAssets[filepath.ToSlash(rel)], requireContent)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", abs, err)
}
repoRel := filepath.ToSlash(filepath.Join(assetsRelPath, filepath.FromSlash(rel)))
createdAt := assetCreatedAt[repoRel]
if createdAt.IsZero() {
createdAt = info.ModTime()
}
hakResources = append(hakResources, assetResource{
Rel: rel,
Resource: resourceInfo.Resource,
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
CreatedAt: createdAt,
ContentID: resourceInfo.ContentID,
})
}
if musicAssets != nil {
for _, asset := range musicAssets.Generated {
if len(allowed) > 0 {
if _, ok := allowed[filepath.ToSlash(asset.Rel)]; !ok {
continue
}
}
hakResources = append(hakResources, asset)
}
}
for _, asset := range generated2DAAssets {
rel := filepath.ToSlash(asset.Rel)
if len(allowed) > 0 {
if _, ok := allowed[rel]; !ok {
continue
}
}
resourceInfo, err := assetResourceFromPath(asset.SourcePath, rel, lfsAssetInfo{}, requireContent)
if err != nil {
return nil, err
}
info, err := os.Stat(asset.SourcePath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", asset.SourcePath, err)
}
hakResources = append(hakResources, assetResource{
Rel: rel,
Resource: resourceInfo.Resource,
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
CreatedAt: info.ModTime(),
ContentID: resourceInfo.ContentID,
})
}
slices.SortFunc(hakResources, func(a, b assetResource) int {
return compareResourceKeys(a.Resource, b.Resource)
})
return hakResources, nil
}
func loadSourceManifestAssetSet(path string) (map[string]struct{}, *BuildManifest, error) {
if strings.TrimSpace(path) == "" {
return nil, nil, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("read source manifest %s: %w", path, err)
}
var manifest BuildManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, nil, fmt.Errorf("parse source manifest %s: %w", path, err)
}
allowed := make(map[string]struct{})
for _, hak := range manifest.HAKs {
for _, rel := range hak.Assets {
allowed[filepath.ToSlash(rel)] = struct{}{}
}
}
return allowed, &manifest, nil
}
func chunksFromManifest(assets []assetResource, entries []BuildManifestHAK) ([]hakChunk, error) {
assetByRel := make(map[string]assetResource, len(assets))
for _, asset := range assets {
assetByRel[filepath.ToSlash(asset.Rel)] = asset
}
chunks := make([]hakChunk, 0, len(entries))
for index, entry := range entries {
chunkAssets := make([]assetResource, 0, len(entry.Assets))
for _, rel := range entry.Assets {
asset, ok := assetByRel[filepath.ToSlash(rel)]
if !ok {
return nil, fmt.Errorf("source manifest references missing asset %s in archive %s", rel, entry.Name)
}
chunkAssets = append(chunkAssets, asset)
}
chunk := hakChunk{
Config: project.HAKConfig{
Name: entry.Group,
Priority: entry.Priority,
MaxBytes: entry.MaxBytes,
Optional: entry.Optional,
},
Index: index,
Name: entry.Name,
Assets: chunkAssets,
Size: erf.ArchiveSize(resourceSlice(chunkAssets)),
}
// A manifest can name two distinct source paths that collapse to the same
// resref+type (e.g. creature/foo.mdl and placeable/foo.mdl); without this
// guard the second would silently shadow the first in the ERF.
if err := ensureUniqueChunkResources(chunk); err != nil {
return nil, err
}
chunks = append(chunks, chunk)
}
return chunks, nil
}
func collectLFSAssetInfo(p *project.Project) (map[string]lfsAssetInfo, error) {
result := make(map[string]lfsAssetInfo)
lfsObjectRoot, err := resolveLFSObjectRoot(p.Root)
if err != nil {
return nil, err
}
for _, rel := range p.Inventory.AssetFiles {
rel = filepath.ToSlash(rel)
abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
info, ok, err := parseLFSPointerFile(abs, lfsObjectRoot)
if err != nil {
return nil, err
}
if ok {
result[rel] = info
}
}
return result, nil
}
func resolveLFSObjectRoot(repoRoot string) (string, error) {
output, err := gitOutput(repoRoot, "rev-parse", "--git-common-dir")
if err != nil {
return filepath.Join(repoRoot, ".git", "lfs", "objects"), nil
}
commonDir := strings.TrimSpace(output)
if commonDir == "" {
return filepath.Join(repoRoot, ".git", "lfs", "objects"), nil
}
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(repoRoot, commonDir)
}
return filepath.Join(filepath.Clean(commonDir), "lfs", "objects"), nil
}
func parseLFSPointerFile(path, lfsObjectRoot string) (lfsAssetInfo, bool, error) {
entry, err := os.Lstat(path)
if err != nil {
return lfsAssetInfo{}, false, fmt.Errorf("stat %s: %w", path, err)
}
if entry.Mode()&os.ModeSymlink != 0 || entry.Size() > 1024 {
return lfsAssetInfo{}, false, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return lfsAssetInfo{}, false, fmt.Errorf("read %s: %w", path, err)
}
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
if !strings.HasPrefix(text, "version https://git-lfs.github.com/spec/v1\n") {
return lfsAssetInfo{}, false, nil
}
info := lfsAssetInfo{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "oid sha256:"):
info.OID = strings.TrimSpace(strings.TrimPrefix(line, "oid sha256:"))
case strings.HasPrefix(line, "size "):
size, parseErr := strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "size ")), 10, 64)
if parseErr != nil {
return lfsAssetInfo{}, false, fmt.Errorf("parse LFS pointer size in %s: %w", path, parseErr)
}
info.Size = size
}
}
if info.OID == "" || info.Size <= 0 {
return lfsAssetInfo{}, false, nil
}
if len(info.OID) >= 4 {
info.ObjectPath = filepath.Join(lfsObjectRoot, info.OID[:2], info.OID[2:4], info.OID)
}
return info, true, nil
}
func assetResourceFromPath(path, rel string, lfsInfo lfsAssetInfo, requireContent bool) (assetResource, error) {
if lfsInfo.OID != "" {
resource, err := lfsPathResource(path, lfsInfo, requireContent)
if err != nil {
return assetResource{}, err
}
return assetResource{
Rel: rel,
Resource: resource,
ContentID: "lfs:" + lfsInfo.OID,
}, nil
}
resource, err := pathResource(path)
if err != nil {
return assetResource{}, err
}
fileHash, err := fileSHA256(path)
if err != nil {
return assetResource{}, err
}
return assetResource{
Rel: rel,
Resource: resource,
ContentID: "file:" + fileHash,
}, nil
}
func lfsPathResource(path string, info lfsAssetInfo, requireContent bool) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported HAK resource extension %q", filepath.Ext(path))
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
sourcePath := ""
if requireContent {
if info.ObjectPath == "" {
return erf.Resource{}, fmt.Errorf("missing downloaded LFS object for %s", path)
}
if _, err := os.Stat(info.ObjectPath); err != nil {
return erf.Resource{}, fmt.Errorf("missing downloaded LFS object for %s: %w", path, err)
}
sourcePath = info.ObjectPath
}
return erf.Resource{
Name: name,
Type: resourceType,
SourcePath: sourcePath,
Size: info.Size,
}, nil
}
func fileSHA256(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
sum := sha256.Sum256(raw)
return fmt.Sprintf("%x", sum[:]), nil
}
func collectGitAssetCreationTimes(p *project.Project) (map[string]time.Time, error) {
result := make(map[string]time.Time, len(p.Inventory.AssetFiles))
if len(p.Inventory.AssetFiles) == 0 {
return result, nil
}
assetsRelPath, err := filepath.Rel(p.Root, p.AssetsDir())
if err != nil {
return result, fmt.Errorf("resolve assets dir relative path: %w", err)
}
assetsRelPath = filepath.ToSlash(assetsRelPath)
cacheFile := filepath.Join(p.Root, ".git", "asset-creation-times.json")
if cache, err := loadCreationTimeCache(cacheFile, p.Root, assetsRelPath, p.Inventory.AssetFiles); err == nil {
return cache, nil
}
output, err := gitOutput(p.Root, "log", "--diff-filter=A", "--format=%ct", "--name-only", "--", assetsRelPath)
if err != nil {
return result, nil
}
wanted := make(map[string]struct{}, len(p.Inventory.AssetFiles))
for _, rel := range p.Inventory.AssetFiles {
wanted[filepath.ToSlash(filepath.Join(assetsRelPath, filepath.FromSlash(rel)))] = struct{}{}
}
var currentCommitTime time.Time
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if sec, err := strconv.ParseInt(line, 10, 64); err == nil {
currentCommitTime = time.Unix(sec, 0).UTC()
continue
}
if currentCommitTime.IsZero() {
continue
}
if _, ok := wanted[line]; !ok {
continue
}
if _, exists := result[line]; exists {
continue
}
result[line] = currentCommitTime
if len(result) == len(wanted) {
break
}
}
_ = saveCreationTimeCache(cacheFile, p.Root, result)
return result, nil
}
type creationTimeCache struct {
Head string `json:"head"`
Times map[string]int64 `json:"times"`
}
func loadCreationTimeCache(cacheFile, root, assetsRel string, assetFiles []string) (map[string]time.Time, error) {
data, err := os.ReadFile(cacheFile)
if err != nil {
return nil, err
}
var cache creationTimeCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
currentHead, err := gitOutput(root, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
currentHead = strings.TrimSpace(currentHead)
if cache.Head != currentHead {
return nil, fmt.Errorf("cache stale")
}
result := make(map[string]time.Time, len(cache.Times))
for k, v := range cache.Times {
result[k] = time.Unix(v, 0).UTC()
}
return result, nil
}
func saveCreationTimeCache(cacheFile, root string, times map[string]time.Time) error {
head, err := gitOutput(root, "rev-parse", "HEAD")
if err != nil {
return err
}
intTimes := make(map[string]int64, len(times))
for k, v := range times {
intTimes[k] = v.Unix()
}
cache := creationTimeCache{
Head: strings.TrimSpace(head),
Times: intTimes,
}
data, err := json.Marshal(cache)
if err != nil {
return err
}
return os.WriteFile(cacheFile, data, 0o644)
}
func gitOutput(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return string(output), nil
}
func sortResources(resources []erf.Resource) {
slices.SortFunc(resources, func(a, b erf.Resource) int {
return compareResourceKeys(a, b)
})
}
func compareResourceKeys(a, b erf.Resource) int {
if cmp := strings.Compare(a.Name, b.Name); cmp != 0 {
return cmp
}
if a.Type < b.Type {
return -1
}
if a.Type > b.Type {
return 1
}
return 0
}
func resourceFromJSON(path string, moduleHakOrder []string) (erf.Resource, error) {
name, extension, err := splitSourceName(path)
if err != nil {
return erf.Resource{}, err
}
resourceType, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported source extension %q", extension)
}
raw, err := os.ReadFile(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("read %s: %w", path, err)
}
var document gff.Document
if err := json.Unmarshal(raw, &document); err != nil {
return erf.Resource{}, fmt.Errorf("parse gff json %s: %w", path, err)
}
if document.FileType == "" {
document.FileType = strings.ToUpper(strings.TrimPrefix(extension, "."))
}
if document.FileVersion == "" {
document.FileVersion = "V3.2"
}
if extension == ".ifo" && name == "module" && len(moduleHakOrder) > 0 {
setModuleHAKList(&document, moduleHakOrder)
}
var buf bytes.Buffer
if err := gff.Write(&buf, document); err != nil {
return erf.Resource{}, fmt.Errorf("encode gff %s: %w", path, err)
}
return erf.Resource{
Name: strings.ToLower(name),
Type: resourceType,
Data: buf.Bytes(),
}, nil
}
func pathResource(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported HAK resource extension %q", filepath.Ext(path))
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
info, err := os.Stat(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("stat %s: %w", path, err)
}
return erf.Resource{
Name: name,
Type: resourceType,
SourcePath: path,
Size: info.Size(),
}, nil
}
func rawResource(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported HAK resource extension %q", filepath.Ext(path))
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
data, err := os.ReadFile(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("read %s: %w", path, err)
}
return erf.Resource{
Name: strings.ToLower(name),
Type: resourceType,
Data: data,
}, nil
}
func splitSourceName(path string) (string, string, error) {
base := filepath.Base(path)
if !strings.HasSuffix(base, ".json") {
return "", "", fmt.Errorf("expected .json source file: %s", path)
}
stem := strings.TrimSuffix(base, ".json")
extension := filepath.Ext(stem)
if extension == "" {
return "", "", fmt.Errorf("source file must include target resource extension before .json: %s", path)
}
return strings.ToLower(strings.TrimSuffix(stem, extension)), strings.ToLower(extension), nil
}
func planHAKChunks(p *project.Project, assets []assetResource) ([]hakChunk, error) {
return planHAKChunksWithPrevious(p, assets, nil)
}
func planHAKChunksWithPrevious(p *project.Project, assets []assetResource, previousManifest *BuildManifest) ([]hakChunk, error) {
if len(assets) == 0 {
return nil, nil
}
if len(p.Config.HAKs) == 0 {
chunk := hakChunk{
Config: project.HAKConfig{
Name: p.Config.Module.ResRef,
Priority: 1,
},
Index: 1,
Name: p.Config.Module.ResRef,
Size: erf.ArchiveSize(resourceSlice(assets)),
Assets: assets,
}
return []hakChunk{chunk}, nil
}
configs := make([]project.HAKConfig, len(p.Config.HAKs))
copy(configs, p.Config.HAKs)
slices.SortFunc(configs, func(a, b project.HAKConfig) int {
if a.Priority != b.Priority {
if a.Priority < b.Priority {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
grouped := make([][]assetResource, len(configs))
for _, asset := range assets {
matched := false
for index, cfg := range configs {
if matchesAnyPattern(asset.Rel, cfg.Include) {
grouped[index] = append(grouped[index], asset)
matched = true
break
}
}
if !matched {
return nil, fmt.Errorf("asset %s does not match any hak include pattern", asset.Rel)
}
}
var chunks []hakChunk
for index, cfg := range configs {
groupAssets := grouped[index]
if len(groupAssets) == 0 {
continue
}
slices.SortFunc(groupAssets, compareAssetBuildOrder)
groupChunks, err := splitHAKGroupWithPrevious(cfg, groupAssets, previousManifest)
if err != nil {
return nil, err
}
chunks = append(chunks, groupChunks...)
}
return chunks, nil
}
func splitHAKGroupWithPrevious(cfg project.HAKConfig, assets []assetResource, previousManifest *BuildManifest) ([]hakChunk, error) {
if previousManifest == nil || cfg.MaxBytes == 0 || !cfg.Split {
return splitHAKGroup(cfg, assets)
}
previousEntries := make([]BuildManifestHAK, 0)
for _, entry := range previousManifest.HAKs {
if entry.Group != cfg.Name {
continue
}
if entry.Priority != cfg.Priority || entry.MaxBytes != cfg.MaxBytes || entry.Optional != cfg.Optional {
return splitHAKGroup(cfg, assets)
}
previousEntries = append(previousEntries, entry)
}
if len(previousEntries) == 0 {
return splitHAKGroup(cfg, assets)
}
assetByRel := make(map[string]assetResource, len(assets))
for _, asset := range assets {
assetByRel[filepath.ToSlash(asset.Rel)] = asset
}
assigned := make(map[string]struct{}, len(assets))
chunks := make([]hakChunk, 0, len(previousEntries))
for _, entry := range previousEntries {
chunkAssets := make([]assetResource, 0, len(entry.Assets))
for _, rel := range entry.Assets {
key := filepath.ToSlash(rel)
asset, ok := assetByRel[key]
if !ok {
continue
}
if _, exists := assigned[key]; exists {
return splitHAKGroup(cfg, assets)
}
chunkAssets = append(chunkAssets, asset)
assigned[key] = struct{}{}
}
if len(chunkAssets) == 0 {
continue
}
chunk := hakChunk{
Config: cfg,
Index: len(chunks) + 1,
Name: entry.Name,
Assets: chunkAssets,
Size: erf.ArchiveSize(resourceSlice(chunkAssets)),
}
if chunk.Size > cfg.MaxBytes {
return splitHAKGroup(cfg, assets)
}
if err := ensureUniqueChunkResources(chunk); err != nil {
return splitHAKGroup(cfg, assets)
}
chunks = append(chunks, chunk)
}
newAssets := make([]assetResource, 0)
for _, asset := range assets {
key := filepath.ToSlash(asset.Rel)
if _, exists := assigned[key]; exists {
continue
}
newAssets = append(newAssets, asset)
}
slices.SortFunc(newAssets, compareAssetBuildOrder)
for _, asset := range newAssets {
inserted := false
for index := len(chunks) - 1; index >= 0; index-- {
candidate, ok := chunkWithAppendedAsset(chunks[index], asset, cfg.MaxBytes)
if !ok {
continue
}
chunks[index] = candidate
inserted = true
break
}
if inserted {
continue
}
chunk := hakChunk{
Config: cfg,
Index: len(chunks) + 1,
Name: nextChunkName(cfg.Name, chunks),
Assets: []assetResource{asset},
Size: erf.ArchiveSize([]erf.Resource{asset.Resource}),
}
if chunk.Size > cfg.MaxBytes {
return nil, fmt.Errorf("asset %s alone exceeds max_bytes for hak %s", asset.Rel, cfg.Name)
}
if err := ensureUniqueChunkResources(chunk); err != nil {
return nil, err
}
chunks = append(chunks, chunk)
}
return chunks, nil
}
func nextChunkName(base string, chunks []hakChunk) string {
used := make(map[string]struct{}, len(chunks))
for _, chunk := range chunks {
used[chunk.Name] = struct{}{}
}
for index := len(chunks) + 1; ; index++ {
name := chunkName(base, index, true)
if _, exists := used[name]; !exists {
return name
}
}
}
func chunkWithAppendedAsset(chunk hakChunk, asset assetResource, maxBytes int64) (hakChunk, bool) {
candidateAssets := append(append([]assetResource{}, chunk.Assets...), asset)
candidate := chunk
candidate.Assets = candidateAssets
candidate.Size = erf.ArchiveSize(resourceSlice(candidateAssets))
if candidate.Size > maxBytes {
return hakChunk{}, false
}
if err := ensureUniqueChunkResources(candidate); err != nil {
return hakChunk{}, false
}
return candidate, true
}
func splitHAKGroup(cfg project.HAKConfig, assets []assetResource) ([]hakChunk, error) {
maxBytes := cfg.MaxBytes
if maxBytes == 0 {
chunk := hakChunk{
Config: cfg,
Index: 1,
Name: cfg.Name,
Assets: assets,
Size: erf.ArchiveSize(resourceSlice(assets)),
}
if err := ensureUniqueChunkResources(chunk); err != nil {
return nil, err
}
return []hakChunk{chunk}, nil
}
var chunks []hakChunk
current := hakChunk{Config: cfg, Index: 1}
for _, asset := range assets {
candidateAssets := append(append([]assetResource{}, current.Assets...), asset)
candidateResources := resourceSlice(candidateAssets)
candidateSize := erf.ArchiveSize(candidateResources)
if len(current.Assets) == 0 {
if candidateSize > maxBytes {
return nil, fmt.Errorf("asset %s alone exceeds max_bytes for hak %s", asset.Rel, cfg.Name)
}
current.Assets = candidateAssets
current.Size = candidateSize
continue
}
if candidateSize <= maxBytes {
current.Assets = candidateAssets
current.Size = candidateSize
continue
}
if !cfg.Split {
return nil, fmt.Errorf("hak %s exceeds max_bytes and split is disabled", cfg.Name)
}
current.Name = chunkName(cfg.Name, current.Index, true)
if err := ensureUniqueChunkResources(current); err != nil {
return nil, err
}
chunks = append(chunks, current)
current = hakChunk{
Config: cfg,
Index: current.Index + 1,
Assets: []assetResource{asset},
Size: erf.ArchiveSize([]erf.Resource{asset.Resource}),
}
if current.Size > maxBytes {
return nil, fmt.Errorf("asset %s alone exceeds max_bytes for hak %s", asset.Rel, cfg.Name)
}
}
if len(current.Assets) > 0 {
current.Name = chunkName(cfg.Name, current.Index, cfg.Split || len(chunks) > 0)
if err := ensureUniqueChunkResources(current); err != nil {
return nil, err
}
chunks = append(chunks, current)
}
return chunks, nil
}
func compareAssetBuildOrder(a, b assetResource) int {
if cmp := a.CreatedAt.Compare(b.CreatedAt); cmp != 0 {
return cmp
}
if cmp := compareResourceKeys(a.Resource, b.Resource); cmp != 0 {
return cmp
}
return strings.Compare(a.Rel, b.Rel)
}
func chunkName(base string, index int, split bool) string {
if !split {
return base
}
return fmt.Sprintf("%s_%02d", base, index)
}
func resourceSlice(assets []assetResource) []erf.Resource {
out := make([]erf.Resource, 0, len(assets))
for _, asset := range assets {
out = append(out, asset.Resource)
}
return out
}
func buildManifestEntry(chunk hakChunk) BuildManifestHAK {
assets := make([]string, 0, len(chunk.Assets))
for _, asset := range chunk.Assets {
assets = append(assets, asset.Rel)
}
return BuildManifestHAK{
Name: chunk.Name,
Group: chunk.Config.Name,
Priority: chunk.Config.Priority,
MaxBytes: chunk.Config.MaxBytes,
SizeBytes: chunk.Size,
Optional: chunk.Config.Optional,
Assets: assets,
ContentHash: chunkContentHash(chunk),
}
}
func chunkContentHash(chunk hakChunk) string {
sum := sha256.New()
fmt.Fprintf(sum, "name:%s\n", chunk.Name)
fmt.Fprintf(sum, "group:%s\n", chunk.Config.Name)
fmt.Fprintf(sum, "priority:%d\n", chunk.Config.Priority)
fmt.Fprintf(sum, "max_bytes:%d\n", chunk.Config.MaxBytes)
fmt.Fprintf(sum, "optional:%t\n", chunk.Config.Optional)
fmt.Fprintf(sum, "size_bytes:%d\n", chunk.Size)
for _, asset := range chunk.Assets {
fmt.Fprintf(sum, "asset:%s\n", asset.Rel)
fmt.Fprintf(sum, "resref:%s\n", asset.Resource.Name)
fmt.Fprintf(sum, "restype:%d\n", asset.Resource.Type)
fmt.Fprintf(sum, "datasize:%d\n", asset.Resource.Size)
fmt.Fprintf(sum, "content:%s\n", asset.ContentID)
}
return fmt.Sprintf("%x", sum.Sum(nil))
}
func loadPreviousBuildManifest(manifestPath string) (*BuildManifest, error) {
raw, err := os.ReadFile(manifestPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read previous hak manifest: %w", err)
}
var manifest BuildManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("parse previous hak manifest: %w", err)
}
return &manifest, nil
}
func reuseExistingHAK(previousManifest *BuildManifest, current BuildManifestHAK, hakPath string) (bool, error) {
if previousManifest == nil {
return false, nil
}
var previous *BuildManifestHAK
for index := range previousManifest.HAKs {
entry := &previousManifest.HAKs[index]
if entry.Name == current.Name {
previous = entry
break
}
}
if previous == nil {
return false, nil
}
if previous.Group != current.Group ||
previous.Priority != current.Priority ||
previous.MaxBytes != current.MaxBytes ||
previous.SizeBytes != current.SizeBytes ||
previous.ContentHash != current.ContentHash ||
!slices.Equal(previous.Assets, current.Assets) {
return false, nil
}
if _, err := os.Stat(hakPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("stat existing hak archive %s: %w", hakPath, err)
}
return true, nil
}
func cleanupStaleGeneratedHAKs(p *project.Project, keepNames map[string]struct{}) error {
paths, err := manifestHAKPaths(p)
if err != nil {
return err
}
for _, path := range paths {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if _, keep := keepNames[name]; keep {
continue
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove stale generated hak %s: %w", path, err)
}
}
legacy := p.HAKArchivePath(p.Config.Module.ResRef)
if _, keep := keepNames[p.Config.Module.ResRef]; !keep {
if err := os.Remove(legacy); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove legacy generated hak %s: %w", legacy, err)
}
}
return nil
}
func matchesAnyPattern(path string, patterns []string) bool {
for _, pattern := range patterns {
if matchPathPattern(filepath.ToSlash(path), filepath.ToSlash(pattern)) {
return true
}
}
return false
}
func matchPathPattern(path, pattern string) bool {
pathSegs := strings.Split(strings.Trim(path, "/"), "/")
patternSegs := strings.Split(strings.Trim(pattern, "/"), "/")
return matchSegments(pathSegs, patternSegs)
}
func cleanupGeneratedHAKs(p *project.Project) error {
paths, err := manifestHAKPaths(p)
if err != nil {
return err
}
for _, path := range paths {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove previous generated hak %s: %w", path, err)
}
}
legacy := p.HAKArchivePath(p.Config.Module.ResRef)
if err := os.Remove(legacy); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove legacy generated hak %s: %w", legacy, err)
}
manifest := p.HAKManifestPath()
if err := os.Remove(manifest); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove previous hak manifest: %w", err)
}
return nil
}
func plannedModuleHAKOrder(p *project.Project) (order []string, err error) {
manifest, err := loadPreviousBuildManifest(p.HAKManifestPath())
if err != nil {
return nil, err
}
if manifest != nil && len(manifest.ModuleHAKs) > 0 {
return append([]string(nil), manifest.ModuleHAKs...), nil
}
musicAssets, err := prepareMusicAssets(p)
if err != nil {
return nil, err
}
defer func() {
if cleanupErr := cleanupPreparedMusicAssets(musicAssets); cleanupErr != nil && err == nil {
err = cleanupErr
}
}()
generated2DAAssets, err := topdata.BuildGenerated2DAAssets(p, nil)
if err != nil {
return nil, err
}
assetResources, err := collectAssetResources(p, false, nil, musicAssets, generated2DAAssets)
if err != nil {
return nil, err
}
chunks, err := planHAKChunks(p, assetResources)
if err != nil {
return nil, err
}
return resolveModuleHAKOrder(p, chunks)
}
func resolveModuleHAKOrder(p *project.Project, chunks []hakChunk) ([]string, error) {
if len(p.Config.Module.HAKOrder) == 0 {
return nil, nil
}
byGroup := map[string][]string{}
optionalNames := map[string]struct{}{}
for _, chunk := range chunks {
if chunk.Config.Optional {
optionalNames[chunk.Name] = struct{}{}
continue
}
byGroup[chunk.Config.Name] = append(byGroup[chunk.Config.Name], chunk.Name)
}
seen := map[string]struct{}{}
var ordered []string
for _, entry := range p.Config.Module.HAKOrder {
if strings.HasPrefix(entry, "group:") {
groupName := strings.TrimPrefix(entry, "group:")
names, ok := byGroup[groupName]
if !ok {
continue
}
for _, name := range names {
if _, exists := seen[name]; exists {
continue
}
ordered = append(ordered, name)
seen[name] = struct{}{}
}
continue
}
if _, optional := optionalNames[entry]; optional {
continue
}
if _, exists := seen[entry]; exists {
continue
}
ordered = append(ordered, entry)
seen[entry] = struct{}{}
}
return ordered, nil
}
func setModuleHAKList(document *gff.Document, hakNames []string) {
list := make(gff.ListValue, 0, len(hakNames))
for _, name := range hakNames {
list = append(list, gff.Struct{
Type: 8,
Fields: []gff.Field{
gff.NewField("Mod_Hak", gff.StringValue(name)),
},
})
}
for index, field := range document.Root.Fields {
if field.Label == "Mod_HakList" {
document.Root.Fields[index] = gff.NewField("Mod_HakList", list)
return
}
}
document.Root.Fields = append(document.Root.Fields, gff.NewField("Mod_HakList", list))
}
func validateForBuild(p *project.Project) error {
report := validator.ValidateProject(p)
if report.HasErrors() {
summary := report.SummaryLines(3)
if len(summary) > 0 {
return fmt.Errorf("validation failed with %d error(s): %s", report.ErrorCount(), summary[0])
}
return fmt.Errorf("validation failed with %d error(s)", report.ErrorCount())
}
return nil
}
func matchSegments(pathSegs, patternSegs []string) bool {
if len(patternSegs) == 0 {
return len(pathSegs) == 0
}
if patternSegs[0] == "**" {
if matchSegments(pathSegs, patternSegs[1:]) {
return true
}
if len(pathSegs) > 0 {
return matchSegments(pathSegs[1:], patternSegs)
}
return false
}
if len(pathSegs) == 0 {
return false
}
ok, err := filepath.Match(patternSegs[0], pathSegs[0])
if err != nil || !ok {
return false
}
return matchSegments(pathSegs[1:], patternSegs[1:])
}