ci / ci (pull_request) Successful in 3m44s
*palcus.itp files are Toolset-generated snapshots: category skeleton plus one descriptor per custom blueprint. Treat them as derived data. Module source now commits only descriptor-free skeletons; build strips any stale descriptors and re-inserts descriptors computed from the blueprints in the source tree (name/STRREF, resref, and CR/faction for creatures), so the DM Creator lists always match what actually ships in the .mod. Compare applies the same projection. Extract skips *palcus.itp entirely, so local Toolset palette churn can no longer leak into module source. Blueprints with PaletteID 255 (hidden) or no PaletteID are omitted, per engine convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
9.7 KiB
Go
367 lines
9.7 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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"
|
|
)
|
|
|
|
type CompareResult struct {
|
|
ModulePath string
|
|
HAKPaths []string
|
|
Checked int
|
|
}
|
|
|
|
type resourceExpectation struct {
|
|
Key string
|
|
Kind string
|
|
Bytes []byte
|
|
}
|
|
|
|
func Compare(p *project.Project) (CompareResult, error) {
|
|
modulePath := p.ModuleArchivePath()
|
|
|
|
var hakPaths []string
|
|
if len(p.Inventory.AssetFiles) > 0 {
|
|
var err error
|
|
hakPaths, err = manifestHAKPaths(p)
|
|
if err != nil {
|
|
return CompareResult{}, err
|
|
}
|
|
}
|
|
if err := ensureBuildIsCurrent(p, modulePath, hakPaths); err != nil {
|
|
return CompareResult{ModulePath: modulePath, HAKPaths: hakPaths}, err
|
|
}
|
|
|
|
moduleArchive, err := readArchive(modulePath)
|
|
if err != nil {
|
|
return CompareResult{}, err
|
|
}
|
|
|
|
expected, err := expectedResources(p)
|
|
if err != nil {
|
|
return CompareResult{}, err
|
|
}
|
|
|
|
actual := archiveIndex(moduleArchive)
|
|
for _, hakPath := range hakPaths {
|
|
hakArchive, err := readArchive(hakPath)
|
|
if err != nil {
|
|
return CompareResult{}, err
|
|
}
|
|
for key, value := range archiveIndex(hakArchive) {
|
|
actual[key] = value
|
|
}
|
|
}
|
|
|
|
var diagnostics []error
|
|
checked := 0
|
|
|
|
expectedKeys := make([]string, 0, len(expected))
|
|
for key := range expected {
|
|
expectedKeys = append(expectedKeys, key)
|
|
}
|
|
slices.Sort(expectedKeys)
|
|
|
|
for _, key := range expectedKeys {
|
|
want := expected[key]
|
|
got, ok := actual[key]
|
|
if !ok {
|
|
diagnostics = append(diagnostics, fmt.Errorf("missing resource in built archives: %s", key))
|
|
continue
|
|
}
|
|
if !bytes.Equal(want.Bytes, got.Data) {
|
|
diagnostics = append(diagnostics, fmt.Errorf("resource content mismatch: %s", key))
|
|
continue
|
|
}
|
|
checked++
|
|
delete(actual, key)
|
|
}
|
|
|
|
if len(actual) > 0 {
|
|
extraKeys := make([]string, 0, len(actual))
|
|
for key := range actual {
|
|
extraKeys = append(extraKeys, key)
|
|
}
|
|
slices.Sort(extraKeys)
|
|
for _, key := range extraKeys {
|
|
diagnostics = append(diagnostics, fmt.Errorf("unexpected extra resource in built archives: %s", key))
|
|
}
|
|
}
|
|
|
|
result := CompareResult{
|
|
ModulePath: modulePath,
|
|
HAKPaths: hakPaths,
|
|
Checked: checked,
|
|
}
|
|
if len(diagnostics) > 0 {
|
|
return result, errors.Join(diagnostics...)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func expectedResources(p *project.Project) (map[string]resourceExpectation, error) {
|
|
out := map[string]resourceExpectation{}
|
|
moduleHakOrder, err := plannedModuleHAKOrder(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
palettes, err := collectPaletteDescriptors(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rel := range p.Inventory.SourceFiles {
|
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
name, extension, err := splitSourceName(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
}
|
|
var document gff.Document
|
|
if err := json.Unmarshal(raw, &document); err != nil {
|
|
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
|
|
}
|
|
if extension == ".ifo" && strings.EqualFold(name, "module") && len(moduleHakOrder) > 0 {
|
|
setModuleHAKList(&document, moduleHakOrder)
|
|
}
|
|
if extension == ".itp" && isPaletteProjectionResref(name) {
|
|
projectPaletteDocument(&document, palettes[strings.ToLower(name)])
|
|
}
|
|
|
|
canonical, err := json.Marshal(document)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal canonical gff json %s: %w", abs, err)
|
|
}
|
|
|
|
out[resourceKey(name, extension)] = resourceExpectation{
|
|
Key: resourceKey(name, extension),
|
|
Kind: "gff-json",
|
|
Bytes: canonical,
|
|
}
|
|
}
|
|
|
|
for _, rel := range p.Inventory.ScriptFiles {
|
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
data, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
}
|
|
name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)))
|
|
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
|
|
out[resourceKey(name, extension)] = resourceExpectation{
|
|
Key: resourceKey(name, extension),
|
|
Kind: "raw",
|
|
Bytes: data,
|
|
}
|
|
}
|
|
|
|
for _, rel := range p.Inventory.AssetFiles {
|
|
abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
|
|
data, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
}
|
|
name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)))
|
|
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
|
|
out[resourceKey(name, extension)] = resourceExpectation{
|
|
Key: resourceKey(name, extension),
|
|
Kind: "raw",
|
|
Bytes: data,
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func ensureBuildIsCurrent(p *project.Project, modulePath string, hakPaths []string) error {
|
|
archivePaths := make([]string, 0, 1+len(hakPaths))
|
|
archivePaths = append(archivePaths, modulePath)
|
|
archivePaths = append(archivePaths, hakPaths...)
|
|
|
|
oldestArchive := time.Time{}
|
|
for _, path := range archivePaths {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat archive %s: %w", path, err)
|
|
}
|
|
if oldestArchive.IsZero() || info.ModTime().Before(oldestArchive) {
|
|
oldestArchive = info.ModTime()
|
|
}
|
|
}
|
|
|
|
sourcePaths := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles))
|
|
for _, rel := range p.Inventory.SourceFiles {
|
|
sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
|
|
}
|
|
for _, rel := range p.Inventory.ScriptFiles {
|
|
sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
|
|
}
|
|
for _, rel := range p.Inventory.AssetFiles {
|
|
sourcePaths = append(sourcePaths, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
|
}
|
|
|
|
newestSource := time.Time{}
|
|
newestPath := ""
|
|
for _, path := range sourcePaths {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat source %s: %w", path, err)
|
|
}
|
|
if newestSource.IsZero() || info.ModTime().After(newestSource) {
|
|
newestSource = info.ModTime()
|
|
newestPath = path
|
|
}
|
|
}
|
|
|
|
if !newestSource.IsZero() && newestSource.After(oldestArchive) {
|
|
return fmt.Errorf("built archives are older than source file %s; run crucible module build or crucible hak build before compare", newestPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readArchive(path string) (erf.Archive, error) {
|
|
input, err := os.Open(path)
|
|
if err != nil {
|
|
return erf.Archive{}, fmt.Errorf("open archive %s: %w", path, err)
|
|
}
|
|
defer input.Close()
|
|
|
|
archive, err := erf.Read(input)
|
|
if err != nil {
|
|
return erf.Archive{}, fmt.Errorf("read archive %s: %w", path, err)
|
|
}
|
|
return archive, nil
|
|
}
|
|
|
|
func archiveIndex(archive erf.Archive) map[string]erf.Resource {
|
|
out := map[string]erf.Resource{}
|
|
for _, resource := range archive.Resources {
|
|
extension, ok := erf.ExtensionForResourceType(resource.Type)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
key := resourceKey(strings.ToLower(resource.Name), extension)
|
|
switch extension {
|
|
case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
|
|
"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl":
|
|
document, err := gff.Read(bytes.NewReader(resource.Data))
|
|
if err != nil {
|
|
out[key] = resource
|
|
continue
|
|
}
|
|
canonical, err := json.Marshal(document)
|
|
if err != nil {
|
|
out[key] = resource
|
|
continue
|
|
}
|
|
resource.Data = canonical
|
|
}
|
|
|
|
out[key] = resource
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resourceKey(name, extension string) string {
|
|
return strings.ToLower(name) + "." + strings.TrimPrefix(strings.ToLower(extension), ".")
|
|
}
|
|
|
|
func manifestHAKPaths(p *project.Project) ([]string, error) {
|
|
manifestPath := p.HAKManifestPath()
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err == nil {
|
|
var manifest BuildManifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, fmt.Errorf("parse hak manifest: %w", err)
|
|
}
|
|
paths := make([]string, 0, len(manifest.HAKs))
|
|
for _, hak := range manifest.HAKs {
|
|
paths = append(paths, p.HAKArchivePath(hak.Name))
|
|
}
|
|
return paths, nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("read hak manifest: %w", err)
|
|
}
|
|
|
|
legacy := filepath.Join(p.BuildDir(), "*.hak")
|
|
paths, err := filepath.Glob(legacy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan hak archives: %w", err)
|
|
}
|
|
filtered := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
if filepath.Base(path) == p.TopDataPackageHAKName() {
|
|
continue
|
|
}
|
|
filtered = append(filtered, path)
|
|
}
|
|
slices.Sort(filtered)
|
|
return filtered, nil
|
|
}
|
|
|
|
func topPackageSourcePaths(p *project.Project) ([]string, error) {
|
|
sourceDir := p.TopDataSourceDir()
|
|
generated2DADir := filepath.Join(sourceDir, "assets", "2da")
|
|
paths := make([]string, 0)
|
|
for _, candidate := range []string{
|
|
filepath.Join(sourceDir, ".tlk_state.json"),
|
|
filepath.Join(sourceDir, "base_dialog.json"),
|
|
} {
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
paths = append(paths, candidate)
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("stat topdata source %s: %w", candidate, err)
|
|
}
|
|
}
|
|
for _, dir := range []string{
|
|
filepath.Join(sourceDir, "data"),
|
|
filepath.Join(sourceDir, "assets"),
|
|
} {
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("stat topdata path %s: %w", dir, err)
|
|
}
|
|
if !info.IsDir() {
|
|
continue
|
|
}
|
|
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() && path == generated2DADir {
|
|
return filepath.SkipDir
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
paths = append(paths, path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan topdata path %s: %w", dir, err)
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|