Racial spell-like abilities (drow darkness, tiefling darkness, aasimar daylight…) only reach the in-game radial when the feat has an OnMenu row in everycls_feat_<class>.2da. Racial feats are granted by race, never by a class, so nothing puts them there automatically — today they are hand-written as ~23 rows in sow-topdatadata/classes/feats/global.json, prepended into every class table.
That hand list is brittle. Adding a racial ability means editing two places — the race feats table (for possession) and global.json (for the radial) — and the two drift: the current build already contained 24 usable racial feats in the race tables but only 23 hand rows in global.json, so one activatable feat was silently missing from the radial.
Solution
Generate the racial radial rows at build time from the single source that already marks them: the UsableFeat column in the race feats tables (race_feat_*.2da). Every feat flagged UsableFeat=1 in any race table gets one cls_feat row injected into every class table, so marking a feat usable in the race table is the only edit needed — the radial row follows automatically. The hand-maintained racial block in global.json is deleted.
User Stories
As a content designer, I want to mark a racial feat UsableFeat=1 in its race table and have it appear on the radial for every class, so that I never edit global.json for racial abilities.
As a content designer, I want adding a race or a class to require no reconciliation of a global radial list, so that the two can never drift out of sync.
As a player, I want every activatable racial ability my race grants to show on my radial regardless of my class, so that none are silently missing.
As a maintainer, I want the generated rows to be byte-identical to the hand rows that were verified in-game (List=3, GrantedOnLevel=99, OnMenu=1), so that the switch to generation changes nothing a player sees.
As a maintainer, I want a passive racial feat (no UsableFeat) to stay off the radial, so that only activatable abilities get buttons.
As a maintainer, I want a racial feat usable for several races to produce exactly one row per class table, so that there are no duplicate radial entries.
As a maintainer, I want generation to be deterministic across builds, so that the 2DA output and row numbering do not churn between runs.
As a maintainer, I want a leftover hand row in global.json to be a no-op rather than a duplicate, so that the sow-topdata cleanup can land independently of this tool change.
Implementation Decisions
Source of truth = race feats tables only. Datasets whose OutputName is race_feat_*.2da, rows with UsableFeat=1. Not feat.2da — a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.
Generated row shape:List=3, GrantedOnLevel=99, OnMenu=1. List=3 keeps the feat off every level-up selection list; GrantedOnLevel=99 is above the level cap so no class ever actually grants it (the field is uint8_t nLevelGranted; the class auto-grants only at reachable levels); OnMenu=1 renders the button once the creature possesses the feat. Possession stays the job of chargen / the login racial-feat sync (sow-codebase#359). -1 was rejected: it is the selectable-list sentinel (pairs with List=0/1) and would leak racial feats into bonus-feat pickers.
Reuse the existing injection machinery. The generated rows are appended to the globalRules already processed by expandClassesFeatRows — same dedup against present rows, same feat-existence check, same label lookup. They differ only in that they are applied unconditionally, not gated by useConfiguredInjections (i.e. regardless of whether a class dataset carries its own global.json), because a leftover hand row must dedup to a no-op during the migration window.
Computed once per build from the collected datasets, threaded through resolveNativeDataset into expandClassesFeatRows. Output sorted by feat key for determinism.
Consumer change (separate repo): delete the racial block from sow-topdatadata/classes/feats/global.json, keeping the non-racial rows. Belongs on the open sow-topdata PR for the racial-radial effort, not here.
Testing Decisions
Good test = observable output, not internals. Given a set of race feats tables, assert the exact set of generated rules (feat id + 3/99/1), that non-usable rows are excluded, that a cross-race duplicate collapses to one, that non-race_feat_ datasets are ignored, and that order is deterministic.
Unit test on the rule generator directly (no build fixture) — the whole decision lives there; the injection/dedup path is already covered by the existing cls_feat global-injection build tests, which continue to pass with the new parameter.
Prior art: the GlobalFeats / global.json injection tests in internal/topdata/topdata_test.go.
End-to-end sanity performed: with the racial block removed from global.json, a real build-topdata run still emits TieflingDarkness 3 99 1 once per class table across all 21, and 24 racial rows total — matching the in-game-verified behaviour and catching the one feat the hand list had dropped.
Out of Scope
Possession / granting the feats to characters — that is sow-codebase#359 (chargen + login racial-feat sync).
Making inert racial spell-like abilities actually cast — sow-codebase#305.
Any change to how non-racial cls_feat injections (combat modes, class-skill masterfeats) work.
Further Notes
The UsableFeat column was previously a passthrough the tool ignored (0 references). This gives it a job; it stays the authoritative marker.
Blast radius is limited to cls_feat_*.2da generation. Other 2DAs are untouched.
## Problem Statement
Racial spell-like abilities (drow darkness, tiefling darkness, aasimar daylight…) only reach the in-game radial when the feat has an `OnMenu` row in **every** `cls_feat_<class>.2da`. Racial feats are granted by race, never by a class, so nothing puts them there automatically — today they are hand-written as ~23 rows in `sow-topdata` `data/classes/feats/global.json`, prepended into every class table.
That hand list is brittle. Adding a racial ability means editing two places — the race feats table (for possession) and `global.json` (for the radial) — and the two drift: the current build already contained **24** usable racial feats in the race tables but only **23** hand rows in `global.json`, so one activatable feat was silently missing from the radial.
## Solution
Generate the racial radial rows at build time from the single source that already marks them: the `UsableFeat` column in the race feats tables (`race_feat_*.2da`). Every feat flagged `UsableFeat=1` in any race table gets one `cls_feat` row injected into every class table, so marking a feat usable in the race table is the only edit needed — the radial row follows automatically. The hand-maintained racial block in `global.json` is deleted.
## User Stories
1. As a content designer, I want to mark a racial feat `UsableFeat=1` in its race table and have it appear on the radial for every class, so that I never edit `global.json` for racial abilities.
2. As a content designer, I want adding a race or a class to require no reconciliation of a global radial list, so that the two can never drift out of sync.
3. As a player, I want every activatable racial ability my race grants to show on my radial regardless of my class, so that none are silently missing.
4. As a maintainer, I want the generated rows to be byte-identical to the hand rows that were verified in-game (`List=3, GrantedOnLevel=99, OnMenu=1`), so that the switch to generation changes nothing a player sees.
5. As a maintainer, I want a passive racial feat (no `UsableFeat`) to stay off the radial, so that only activatable abilities get buttons.
6. As a maintainer, I want a racial feat usable for several races to produce exactly one row per class table, so that there are no duplicate radial entries.
7. As a maintainer, I want generation to be deterministic across builds, so that the 2DA output and row numbering do not churn between runs.
8. As a maintainer, I want a leftover hand row in `global.json` to be a no-op rather than a duplicate, so that the `sow-topdata` cleanup can land independently of this tool change.
## Implementation Decisions
- **Source of truth = race feats tables only.** Datasets whose `OutputName` is `race_feat_*.2da`, rows with `UsableFeat=1`. Not `feat.2da` — a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.
- **Generated row shape:** `List=3, GrantedOnLevel=99, OnMenu=1`. `List=3` keeps the feat off every level-up selection list; `GrantedOnLevel=99` is above the level cap so no class ever actually grants it (the field is `uint8_t nLevelGranted`; the class auto-grants only at reachable levels); `OnMenu=1` renders the button once the creature possesses the feat. Possession stays the job of chargen / the login racial-feat sync (`sow-codebase#359`). `-1` was rejected: it is the *selectable-list* sentinel (pairs with `List=0/1`) and would leak racial feats into bonus-feat pickers.
- **Reuse the existing injection machinery.** The generated rows are appended to the `globalRules` already processed by `expandClassesFeatRows` — same dedup against present rows, same feat-existence check, same label lookup. They differ only in that they are applied **unconditionally**, not gated by `useConfiguredInjections` (i.e. regardless of whether a class dataset carries its own `global.json`), because a leftover hand row must dedup to a no-op during the migration window.
- **Computed once per build** from the collected datasets, threaded through `resolveNativeDataset` into `expandClassesFeatRows`. Output sorted by feat key for determinism.
- **Consumer change (separate repo):** delete the racial block from `sow-topdata` `data/classes/feats/global.json`, keeping the non-racial rows. Belongs on the open `sow-topdata` PR for the racial-radial effort, not here.
## Testing Decisions
- **Good test = observable output, not internals.** Given a set of race feats tables, assert the exact set of generated rules (feat id + `3/99/1`), that non-usable rows are excluded, that a cross-race duplicate collapses to one, that non-`race_feat_` datasets are ignored, and that order is deterministic.
- **Unit test** on the rule generator directly (no build fixture) — the whole decision lives there; the injection/dedup path is already covered by the existing `cls_feat` global-injection build tests, which continue to pass with the new parameter.
- **Prior art:** the `GlobalFeats` / `global.json` injection tests in `internal/topdata/topdata_test.go`.
- **End-to-end sanity performed:** with the racial block removed from `global.json`, a real `build-topdata` run still emits `TieflingDarkness 3 99 1` once per class table across all 21, and 24 racial rows total — matching the in-game-verified behaviour and catching the one feat the hand list had dropped.
## Out of Scope
- Possession / granting the feats to characters — that is `sow-codebase#359` (chargen + login racial-feat sync).
- Making inert racial spell-like abilities actually cast — `sow-codebase#305`.
- Any change to how non-racial `cls_feat` injections (combat modes, class-skill masterfeats) work.
## Further Notes
- The `UsableFeat` column was previously a passthrough the tool ignored (0 references). This gives it a job; it stays the authoritative marker.
- Blast radius is limited to `cls_feat_*.2da` generation. Other 2DAs are untouched.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem Statement
Racial spell-like abilities (drow darkness, tiefling darkness, aasimar daylight…) only reach the in-game radial when the feat has an
OnMenurow in everycls_feat_<class>.2da. Racial feats are granted by race, never by a class, so nothing puts them there automatically — today they are hand-written as ~23 rows insow-topdatadata/classes/feats/global.json, prepended into every class table.That hand list is brittle. Adding a racial ability means editing two places — the race feats table (for possession) and
global.json(for the radial) — and the two drift: the current build already contained 24 usable racial feats in the race tables but only 23 hand rows inglobal.json, so one activatable feat was silently missing from the radial.Solution
Generate the racial radial rows at build time from the single source that already marks them: the
UsableFeatcolumn in the race feats tables (race_feat_*.2da). Every feat flaggedUsableFeat=1in any race table gets onecls_featrow injected into every class table, so marking a feat usable in the race table is the only edit needed — the radial row follows automatically. The hand-maintained racial block inglobal.jsonis deleted.User Stories
UsableFeat=1in its race table and have it appear on the radial for every class, so that I never editglobal.jsonfor racial abilities.List=3, GrantedOnLevel=99, OnMenu=1), so that the switch to generation changes nothing a player sees.UsableFeat) to stay off the radial, so that only activatable abilities get buttons.global.jsonto be a no-op rather than a duplicate, so that thesow-topdatacleanup can land independently of this tool change.Implementation Decisions
OutputNameisrace_feat_*.2da, rows withUsableFeat=1. Notfeat.2da— a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.List=3, GrantedOnLevel=99, OnMenu=1.List=3keeps the feat off every level-up selection list;GrantedOnLevel=99is above the level cap so no class ever actually grants it (the field isuint8_t nLevelGranted; the class auto-grants only at reachable levels);OnMenu=1renders the button once the creature possesses the feat. Possession stays the job of chargen / the login racial-feat sync (sow-codebase#359).-1was rejected: it is the selectable-list sentinel (pairs withList=0/1) and would leak racial feats into bonus-feat pickers.globalRulesalready processed byexpandClassesFeatRows— same dedup against present rows, same feat-existence check, same label lookup. They differ only in that they are applied unconditionally, not gated byuseConfiguredInjections(i.e. regardless of whether a class dataset carries its ownglobal.json), because a leftover hand row must dedup to a no-op during the migration window.resolveNativeDatasetintoexpandClassesFeatRows. Output sorted by feat key for determinism.sow-topdatadata/classes/feats/global.json, keeping the non-racial rows. Belongs on the opensow-topdataPR for the racial-radial effort, not here.Testing Decisions
3/99/1), that non-usable rows are excluded, that a cross-race duplicate collapses to one, that non-race_feat_datasets are ignored, and that order is deterministic.cls_featglobal-injection build tests, which continue to pass with the new parameter.GlobalFeats/global.jsoninjection tests ininternal/topdata/topdata_test.go.global.json, a realbuild-topdatarun still emitsTieflingDarkness 3 99 1once per class table across all 21, and 24 racial rows total — matching the in-game-verified behaviour and catching the one feat the hand list had dropped.Out of Scope
sow-codebase#359(chargen + login racial-feat sync).sow-codebase#305.cls_featinjections (combat modes, class-skill masterfeats) work.Further Notes
UsableFeatcolumn was previously a passthrough the tool ignored (0 references). This gives it a job; it stays the authoritative marker.cls_feat_*.2dageneration. Other 2DAs are untouched.