Content Validation¶
Summary: One validation spine gates content:
UEditorValidatorBasevalidators run on save in the editor, and the same checks run headlessly via theEternalValidationcommandlet — invoked by CI on every PR, by the pre-push hook whenContent/**changes, or manually viaTools/run_validation.ps1. Broken data assets fail loudly at save time, push time, and PR time instead of at runtime.
Table of Contents¶
- Architecture
- Why This Design?
- Validator Registry
- Delivery Mechanisms
- Running Manually
- Output & Exit Codes
- Adding a Validator
- Source References
- Related Systems
- Recent Changes
Architecture¶
┌──────────────────────────────────────────────┐
│ UEditorValidatorBase subclasses │
│ (one per asset class; thin wrappers) │
└──────────┬───────────────────────────────────┘
│ delegate all logic to
▼
┌──────────────────────────────────────────────┐
│ *ValidationCore (plain C++, spec-testable) │
│ Itemization / Domain / Kit / Ability / │
│ Narrative ValidationCore │
└──────────┬───────────────────────────────────┘
│ invoked by
▼
┌────────────────┬────────────────┬────────────┐
│ Editor on-save │ EternalValidation commandlet │
│ (automatic) │ (CI · pre-push · manual) │
└────────────────┴──────────────────────────────┘
Why This Design?¶
| Decision | Rationale |
|---|---|
Logic in *ValidationCore, not the validator |
Cores are plain C++ → unit-testable via specs without editor validation plumbing |
| One commandlet, three callers | Editor, pre-push, and CI can never drift apart — they run the identical checks |
| Warnings vs errors | Errors block; warnings surface judgment calls (e.g. legitimate cross-slot room reuse). -strict escalates warnings to failures |
| Cross-asset checks live in the commandlet pass | On-save sees one asset; uniqueness/reference checks (duplicate DomainTag, map HQ nodes) need the full set |
Validator Registry¶
| Validator | Assets | Catches |
|---|---|---|
UDomainConfigValidator |
UDomainDungeonConfig, topologies, world maps |
Topology SlotTag → non-empty pool; room SlotTag self-identification (tag-less pooled room = error, tag ≠ pool key = warning); HQ/Standard topology mismatch; BossEncounter presence; cross-asset DomainTag uniqueness; map HQ node IDs exist and are HQ-type; env-tile Level set + uniform tile size per pool |
URemnantItemPoolValidator |
Remnant item pools | Era set; Desire TaskTags in tracker routing table; Target > 0; Echo type + fixed tier values; Burden strictly-negative ranges; factory-refusal shapes |
UModifierPoolValidator |
Modifier pools | Pool integrity + asset↔JSON mirror drift (parses the mirror exactly like Eternal.Modifiers.ImportJson, so drift means "importing would change the pool"; scans Tools/*.json marked {"sync": true} — new mirrors auto-enroll) |
UEternalKitValidator |
DA_CharacterClassInfo kits |
Kit composition rules (see Ability Kits doc) |
UEternalAbilityValidator |
Ability blueprints | Archetype-aware ability configuration. An archetype's TemplateBP package is exempt from the warnings that describe an unfinished ability (no StartupInputTag, no projectile travel audio, no montage) — the wizard's stamped copy still gets them. Errors (contract slots, AOE-with-no-spawn-path) still fire on templates. Matched by package, never by class |
UItemBaseTypeValidator, UItemManifestValidator, ULootTableValidator, UCraftingRecipeValidator |
Itemization assets | See Itemization Tooling |
UNarrativeValidator |
Quest/dialogue content | Tag-graph integrity (narrative track — NarrativeValidation commandlet is its own headless entry point) |
Room door geometry (grid-edge cells, duplicates, Level set) is validated by the ProceduralDungeon
plugin's own URoomData::IsDataValid, which rides the same commandlet — not duplicated in project validators.
Checks worth knowing about¶
Most validator checks are local contract checks. A few exist because they catch a failure that is otherwise completely silent — the class of bug this whole system is for:
| Check | Catches | Why it is silent otherwise |
|---|---|---|
Melee attack channel (CheckMeleeAttackChannel) |
A melee ability whose StartupInputTag sits outside Input.LMB / Input.RMB / Input.Attack.Charged |
Light-vs-heavy is resolved from that tag, so an off-channel slot prices, scales and breaks stance as a light swing with no runtime complaint. The check mirrors the runtime's hierarchical match exactly, so "legal" is precisely the set the swing can still classify. Rebinding through a kit's InputTagOverride is safe — that moves the spec binding, not the class default |
| Status-panel status tag | A status-panel effect whose StatusTag is a category rather than a Status.* leaf |
A registered non-Status tag validates and renders fine — until a second effect uses the same category tag and silently collapses into the first icon, because the panel keys one icon per status tag |
| Fraction-bake drift | A baked TiersValueRanges that disagrees with fraction × curve at the tier's unlock level |
Derived data that was hand-edited, or a stale bake after a fraction/curve/TierMap change. Both read as ordinary authored numbers. The error names the repair commands |
| Tray tooltip category tag | A granted-power entry with no category tag | The tray tooltip has nothing to label the entry with, and renders a typeless row |
Delivery Mechanisms¶
| Mechanism | Trigger | Behavior |
|---|---|---|
| Editor on-save | Saving any covered asset | Immediate errors/warnings in editor |
CI (pr-validation.yml) |
Every PR to main (paths include Content/** and .github/workflows/**) |
"Content validation" step runs the commandlet over /Game/DataAssets; report echoed into the job log; non-zero exit fails the PR |
Pre-push hook (.githooks/pre-push) |
Pushed range touches Content/** |
Runs Tools/run_validation.ps1 -NoBuild; aborts push on failure. Escape hatch: SKIP_CONTENT_VALIDATION=1 git push. Skips with a warning when UE_ENGINE_PATH is unset (CI still gates) |
| Manual | On demand | Tools/run_validation.ps1 (below) |
A sibling CI guard, docs-link-check.yml, validates Documentation/ links and Source/... citations
(Tools/check_doc_links.ps1) and also triggers on Source/** so moving source files flags the docs they strand.
Link validation covers #heading-anchors too — both same-document (](#section)) and cross-document
(](./Other.md#section)) — resolved against the target file's real headings using the GitHub slug rule
(lowercase, drop everything but letters/digits/space/underscore/hyphen, then each space becomes a hyphen).
Two consequences when hand-writing an anchor: a symbol between words leaves the hyphen it stood between
(Break → Launch → #break--launch), and a repeated heading in one file takes a -1, -2 suffix.
Running Manually¶
Mirrors run_tests.ps1: engine root from -EnginePath or UE_ENGINE_PATH; builds the editor target first
unless -NoBuild. Direct commandlet form: UnrealEditor-Cmd.exe <project> -run=EternalValidation -paths=... [-strict].
Output & Exit Codes¶
- Report:
Saved/Validation/ContentValidation.md - Summary line:
VALIDATION: requested=N checked=N valid=N invalid=N warnings=N skipped=N unable=N mirrordrift=N - The commandlet owns its exit code (
UseCommandletResultAsExitCode) — non-zero on any invalid asset (or any warning under-strict). Do not trust the pythonscript wrapper's exit code (see the commandlet-exit-code learning).
Adding a Validator¶
- Put the logic in a
*ValidationCore(or extend an existing one) — plain functions over the asset's data. - Spec the core under
Source/ProjectEternalEditor/Private/Tests/. - Wrap it in a
UEditorValidatorBasesubclass inSource/ProjectEternalEditor/*/Validation/. - Nothing else — on-save, pre-push, and CI pick it up automatically through the commandlet.
Dead-gate rule (any check that branches on an optional field's presence). A check keyed on "does field X exist / is it set?" goes silent, not strict, when X is later renamed or deleted — reflection finds nothing, the branch stops running, and a green sweep hides the regression. So:
- Key the branch on class identity or a catalog declaration, not on
FindPropertyByName != null. - Make the field's disappearance an explicit error ("archetype expects slot X but the property does not exist"), so a removed field fails loudly instead of retiring the check.
- Pin it: a spec case asserting the field/route still resolves for the class that owns it. Example:
AbilityValidation.spec.cpppinsActionTagon both enemy bases and each shipped base'sEMontageRoute, so a route flipped to itsNonedefault (no check) goes red.
Source References¶
| Component | Location |
|---|---|
| Commandlet | Source/ProjectEternalEditor/Public/Validation/EternalValidationCommandlet.h |
| Validators + cores | Source/ProjectEternalEditor/Public/Validation/ (impl in Private/Validation/) |
| Manual runner | Tools/run_validation.ps1 |
| Pre-push gate | .githooks/pre-push |
| CI step | .github/workflows/pr-validation.yml → "Content validation" |
| Doc-link checker | Tools/check_doc_links.ps1 + .github/workflows/docs-link-check.yml |
Related Systems¶
- Testing — spec coverage for validation cores;
ProjectEternal.Content.Dungeon.GenerationRegressionexercises the generation pipeline the domain validators protect - Itemization Tooling — itemization validator details, JSON mirror workflow
- CI/CD Pipeline — where the CI step runs
- Data Authoring Pipeline — JSON source-of-truth strategy the mirror-drift check protects
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-07-27 | Status-panel effects must name a Status.* leaf, not a category |
A second effect sharing a category tag would have silently collapsed into the first panel icon |
| 2026-07-26 | CheckMeleeAttackChannel: melee abilities must be authored on a recognised attack channel |
An off-channel slot silently made a heavy swing behave as a light one in cost, scaling and stance pressure |
| 2026-07-24 | Fraction-bake drift rule for flat damage tiers | Hand-edited or stale derived ranges now fail at the on-save / pre-push / CI gate instead of shipping as plausible numbers |
| 2026-07-20 | Granted-power tray tooltips require a category tag | Typeless tray rows caught at authoring time |
| 2026-07-09 | CheckMontageWiring dispatches off the catalog row's EMontageRoute instead of an IsA ladder (A3); AOE/melee/leap exemptions are now data. Dead-gate rule codified above. |
New montage behaviour = a route value + spec pin, not a validator edit; the two enemy bases stay an explicit class check |
| 2026-07-08 | Ability archetype templates exempt from the unfinished-ability warnings — input slot, travel audio, montage (IsArchetypeTemplatePackage) |
All four affected TPL_* validate clean; ability sweep 91 → 80 warnings, 0 errors lost |
| 2026-07-08 | Room SlotTag self-identification check (B-4); mirror-drift check (B-10); doc-symbol CI pass (B-9) |
Caught all 14 pooled rooms tag-less on first run |
| 2026-07-07 | Validation spine shipped (B-1…B-3, B-5): commandlet + CI + pre-push + on-save | First live run: 13/201 assets invalid, triaged to 0 |