Compare commits

..
3 Commits
Author SHA1 Message Date
archvillainette 9747384838 fix(extract): keep committed palette skeletons out of stale cleanup (#96)
build-binaries / build-binaries (push) Successful in 2m50s
Extract skips writing *palcus.itp back to source (build projects them from blueprints, #50), but the skip branch never marked the skeleton target as desired, so cleanupStaleFiles deleted the committed source/palettes/*.itp.json on every extract — shipping modules with empty custom palettes. Fix marks the skipped target desired; regression test builds a module with a committed skeleton and asserts extract leaves it in place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #96

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-04 08:51:12 +00:00
archvillainette b47a8a7afd Generate racial radial rows from race UsableFeat (#94) (#95)
build-binaries / build-binaries (push) Successful in 2m26s
Closes #94.

Generates the racial radial rows at build time from the `UsableFeat` column in the race feats tables, replacing the hand-maintained racial block in `sow-topdata` `data/classes/feats/global.json`.

## Why

Racial spell-like abilities reach the in-game radial only via an `OnMenu` row in every `cls_feat_<class>.2da`. Racial feats are granted by race, never by a class, so nothing adds them automatically — they were hand-written and prepended into every class table. The list drifts: this build already had **24** usable racial feats in the race tables but only **23** hand rows, so one activatable feat was silently missing.

## What

- `racialUsableFeatRules()` scans `race_feat_*.2da` datasets, collects `UsableFeat=1` feats, emits one rule each: `List=3, GrantedOnLevel=99, OnMenu=1`. Deduped across races, sorted for deterministic output.
- Row shape is exactly the in-game-verified hand rows. `List=3` keeps it off every level-up selection list; `GrantedOnLevel=99` is above the level cap so no class ever actually grants it (`nLevelGranted` is `uint8_t`); `OnMenu=1` renders the button once the creature possesses the feat. Possession stays chargen / the login racial-feat sync (`sow-codebase#359`).
- Reuses the existing `globalRules` injection path (same dedup, feat-existence check, label lookup) but applies **unconditionally**, so a leftover hand row in `global.json` deduplicates to a no-op — the `sow-topdata` cleanup lands separately.
- Not sourced from `feat.2da`: a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.

## Tests

- `racial_feat_rules_test.go`: usable-only, non-usable excluded, cross-race dedup, non-`race_feat_` ignored, deterministic order.
- Existing `cls_feat` global-injection build tests still pass with the new parameter.
- End-to-end: with the racial block removed from `global.json`, a real `build-topdata` emits `TieflingDarkness 3 99 1` once per table across all 21, 24 racial rows total.

## Merge order

The `sow-topdata` `global.json` racial-row deletion depends on this — it must ship first (or the racial radial vanishes on the next topdata build with the released tool).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #95

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-03 18:58:43 +00:00
archvillainette f23009ed50 verify is what tells you which keys to purge (#91)
build-binaries / build-binaries (push) Successful in 2m48s
Running the repair disproved the advice #90 landed an hour earlier.

"Purge the zone, then believe `verify`" assumed the stale set was unknowable. It is not. `verify` reads the edge, so the run straight after a repair names every key the edge is still serving stale — a survey, not a verdict. Purge those, re-run, and the second run is the verdict.

The measured numbers are the whole argument:

| | |
| --- | --- |
| blobs rewritten at the origin | 2,603 |
| blobs stale at the edge | **8** |

All eight were ones a failed player sync had pulled ninety minutes before the backfill. The edge only caches what someone fetched, so purging the zone would have cooled 69,169 objects to fix 8.

Full sweep after the targeted purge: `verified 69177 of 69177 blobs behind 72544 resources: 0 failures, 14887519535 bytes checked`. #75's gate is met.

Docs only. Runbook side in sow-platform.

Refs #88, #89, #75.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #91
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-01 09:20:16 +00:00
6 changed files with 205 additions and 6 deletions
+7 -1
View File
@@ -126,8 +126,14 @@ func extractArchiveResources(p *project.Project, archive erf.Archive, desired ma
}
// *palcus.itp are Toolset-generated palette projections; the module
// build regenerates them from source blueprints, so extraction never
// writes them back into source.
// writes them back into source. The committed skeleton still has to
// survive stale cleanup, so mark its target as desired.
if ext == "itp" && isPaletteProjectionResref(resource.Name) {
effective := p.EffectiveConfig()
target, err := extractionTarget(p, "paths.source", effective.Paths.Source, p.SourceDir(), sourceSubdir(ext), strings.ToLower(resource.Name)+"."+ext+".json")
if err == nil {
desired[target] = struct{}{}
}
skippedCount++
continue
}
+82
View File
@@ -3312,6 +3312,88 @@ func TestExtractOverwritesAndRemovesStaleFiles(t *testing.T) {
}
}
func TestExtractKeepsPaletteSkeletonsThroughStaleCleanup(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module"))
mustMkdir(t, filepath.Join(root, "src", "palettes"))
mustMkdir(t, filepath.Join(root, "assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
}
}
`)
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{
"label": "Mod_Name",
"type": "CExoString",
"value": "Original Module"
}
]
}
}
`)
skeletonPath := filepath.Join(root, "src", "palettes", "creaturepalcus.itp.json")
mustWriteFile(t, skeletonPath, `{
"file_type": "ITP ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{
"label": "MAIN",
"type": "List",
"value": []
}
]
}
}
`)
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
if _, err := BuildModule(p); err != nil {
t.Fatalf("build module: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("rescan before extract: %v", err)
}
result, err := Extract(p)
if err != nil {
t.Fatalf("extract: %v", err)
}
if result.Removed != 0 {
t.Fatalf("expected no removed files, got %d", result.Removed)
}
if _, err := os.Stat(skeletonPath); err != nil {
t.Fatalf("expected palette skeleton to survive extract, stat err=%v", err)
}
}
func TestExtractMergesConfiguredGFFJSONFieldsAndLists(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module"))
+2 -1
View File
@@ -98,8 +98,9 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat
}
results := make([]Generated2DAAsset, 0, len(collected))
racialFeatRules := racialUsableFeatRules(collected)
for _, dataset := range collected {
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, nil)
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, racialFeatRules, nil)
if err != nil {
return nil, err
}
+59 -4
View File
@@ -530,6 +530,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
groupStats := nativeCompileGroupStats(collected)
currentGroup := ""
sidecars := newNativeSidecarCollector()
racialFeatRules := racialUsableFeatRules(collected)
for _, dataset := range collected {
group := nativeCompileGroup(dataset.Dataset.Name)
if group != currentGroup {
@@ -542,7 +543,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
stats.SourceFragments,
))
}
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars)
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, racialFeatRules, sidecars)
if err != nil {
return BuildResult{}, err
}
@@ -3996,13 +3997,13 @@ func normalizeGlobalReferenceID(value string) string {
return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "")
}
func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (map[string]any, error) {
func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, racialFeatRules []project.TopDataClassFeatGlobalRule, sidecars *nativeSidecarCollector) (map[string]any, error) {
rows := dataset.Rows
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
classSkills := buildClassSkillsIndex(tableRegistry, classKey)
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, !dataset.Dataset.HasGlobalInjections)
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, racialFeatRules, !dataset.Dataset.HasGlobalInjections)
if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
}
@@ -4051,12 +4052,18 @@ var (
}
)
func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig, useConfiguredInjections bool) ([]map[string]any, error) {
func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig, racialFeatRules []project.TopDataClassFeatGlobalRule, useConfiguredInjections bool) ([]map[string]any, error) {
globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{}
if useConfiguredInjections {
globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections)
}
// Racial usable-feat rows are generated from the race feats tables, not the
// hand-authored class-feat injections, so they apply to every class table
// regardless of whether that dataset carries its own global.json (which is
// what gates useConfiguredInjections). Deduped below against rows already
// present, so a leftover hand row in global.json is a no-op, not a double.
globalRules = append(globalRules, racialFeatRules...)
injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules))
presentRefIDs := make(map[string]struct{}, len(rows))
for _, row := range rows {
@@ -4134,6 +4141,54 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK
return combined, nil
}
// racialUsableFeatRules builds one class-feat injection rule per feat marked
// UsableFeat=1 in any race feats table (race_feat_*.2da). Racial feats are
// granted by race, never by a class, so an activatable one needs a menu-only
// cls_feat row to reach the client radial: List=3 keeps it off every level-up
// selection list, GrantedOnLevel=99 sits above the level cap so no class ever
// actually grants it, and OnMenu=1 renders the button once the creature holds
// the feat (possession comes from chargen / the login racial-feat sync). This
// replaces the hand-maintained racial rows in classes/feats/global.json - mark
// UsableFeat in the race table and the radial row follows automatically.
func racialUsableFeatRules(collected []nativeCollectedDataset) []project.TopDataClassFeatGlobalRule {
seen := map[string]struct{}{}
rules := []project.TopDataClassFeatGlobalRule{}
for _, ds := range collected {
if !strings.HasPrefix(ds.Dataset.OutputName, "race_feat_") || !strings.HasSuffix(ds.Dataset.OutputName, ".2da") {
continue
}
for _, row := range ds.Rows {
if usable, err := asInt(fieldValue(row, "UsableFeat")); err != nil || usable != 1 {
continue
}
featRef, ok := row["FeatIndex"].(map[string]any)
if !ok {
continue
}
featID, _ := featRef["id"].(string)
if featID == "" {
continue
}
if _, dup := seen[featID]; dup {
continue
}
seen[featID] = struct{}{}
rules = append(rules, project.TopDataClassFeatGlobalRule{
Feat: featID,
List: "3",
GrantedOnLevel: "99",
OnMenu: "1",
})
}
}
// Discovery order across files and the dedup map are both unordered; sort so
// the injected rows (and the resulting 2DA row numbering) are deterministic.
slices.SortFunc(rules, func(a, b project.TopDataClassFeatGlobalRule) int {
return strings.Compare(a.Feat, b.Feat)
})
return rules
}
func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) {
if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 {
return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules
@@ -0,0 +1,54 @@
package topdata
import (
"reflect"
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func raceFeatDataset(output string, rows ...map[string]any) nativeCollectedDataset {
return nativeCollectedDataset{
Dataset: nativeDataset{OutputName: output},
Rows: rows,
}
}
func usableRow(featID string, usable any) map[string]any {
row := map[string]any{"FeatIndex": map[string]any{"id": featID}}
if usable != nil {
row["UsableFeat"] = usable
}
return row
}
func TestRacialUsableFeatRules(t *testing.T) {
collected := []nativeCollectedDataset{
raceFeatDataset("race_feat_ddrw.2da",
usableRow("feat:keen_sense", nil), // passive, no UsableFeat -> skipped
usableRow("feat:darkvision", 1), // usable
usableRow("feat:use_poison", 0), // explicitly not usable -> skipped
usableRow("feat:drow/faerie_fire", "1"),// usable, string form
),
raceFeatDataset("race_feat_tief.2da",
usableRow("feat:darkvision", 1), // duplicate across races -> collapses to one
usableRow("feat:tiefling/darkness", 1),
),
raceFeatDataset("feat.2da", // not a race feats table -> ignored entirely
usableRow("feat:power_attack", 1),
),
}
got := racialUsableFeatRules(collected)
want := []project.TopDataClassFeatGlobalRule{
{Feat: "feat:darkvision", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
{Feat: "feat:drow/faerie_fire", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
{Feat: "feat:tiefling/darkness", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
}
// Slice is sorted by Feat, so order is deterministic.
if !reflect.DeepEqual(got, want) {
t.Fatalf("rules: got %+v, want %+v", got, want)
}
}
+1
View File
@@ -1523,6 +1523,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T)
nil,
project.TopDataClassFeatInjectionConfig{},
nil,
nil,
)
if err != nil {
t.Fatalf("resolveNativeDataset failed: %v", err)