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) |
UMontageTableValidator |
Enemy montage tables (FMontageAction rows) |
Combo-string rows (Action.Attack.Combo.*): null/empty step montages; step shorter than MinComboStepSeconds (proxy-skip risk); missing hit / warp windows; step slot group differing from the table's hit-react rows; dead StepParams — cross-checks every loaded UEnemyComboAbility CDO keyed to the row (loaded-only scope; the commandlet sweep loads the full Blueprint set). Core: MontageTableValidationCore.h |
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,/Game/Gameplay/GameplayAbilities,/Game/DataTables; 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¶
Tools\run_validation.ps1 [-Paths <comma-separated roots>] [-Strict] [-NoBuild] [-EnginePath <engine root>]
-Paths defaults to /Game/DataAssets,/Game/Gameplay/GameplayAbilities,/Game/DataTables — the same three roots CI
sweeps (the bare commandlet's own default is /Game/DataAssets only). 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 |
Commandlets¶
Every headless entry point in ProjectEternalEditor (-run=<Name> on UnrealEditor-Cmd.exe). Validation is
only one of them; the rest are listed here so there is a single place to look. Header paths are relative to
Source/ProjectEternalEditor/.
| Commandlet | Purpose | Header |
|---|---|---|
EternalValidation |
The content-validation spine: runs every registered validator over -paths= roots, plus the cross-asset passes (mirror drift, tier authority, dialogue sidecar freshness — every dialogue, independent of -paths), writes Saved/Validation/ContentValidation.md, non-zero exit on errors (-strict escalates warnings) |
Public/Validation/EternalValidationCommandlet.h |
NarrativeValidation |
Narrative tag-graph integrity + dialogue .dlg.json sidecar presence/freshness (-ExportSidecars rewrites sidecars, -SkipSidecars graph only). "Fresh" means an editor save would leave the file untouched: the check runs the save-time transform (UDlgDialogue::PrepareForExport — graph recompile, GUID-map sort) in memory before comparing, writes nothing |
Public/Validation/NarrativeValidationCommandlet.h |
DialogueImport |
Builds DlgSystem dialogue assets from their committed .dlg.json sidecars: -run=DialogueImport /Game/Dialogue/Foo/BarDialogue [...]. A zero-node import is a failure (not saved); the editor graph is rebuilt via ClearGraph() before save because UDlgDialogue::PreSave otherwise recompiles the empty default graph over the imported nodes |
Public/Validation/DialogueImportCommandlet.h |
AudioAudit |
Audio-completeness audit — surface-taxonomy parity, asset naming, mix-graph registration, unmapped combat-effect sounds; report-only unless -strict |
Public/Audio/AudioAuditCommandlet.h |
BuildSweep |
Headless build-power sweep over every UBuildLoadoutAsset / preset across area-level × threat-tier; paired JSON + MD reports in Saved/BuildSweep/ |
Public/BuildLab/BuildSweepCommandlet.h |
BossBTBuilder |
Rebuilds the Cultist Boss phase behavior trees (runtime tree + editor graph) headlessly; -dumponly to inspect |
Private/BossBTBuilder/BossBTBuilderCommandlet.h |
BarrageCastTrim |
One-shot montage surgery: crops the leftover jump-back opening from the boss orb-barrage cast montage and shifts its notifies to match | Private/BossBTBuilder/BarrageCastTrimCommandlet.h |
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-09-07 | Dialogue sidecar freshness rides the EternalValidation pass (pre-push + CI), and compares against the save-time export instead of the serialized nodes. DlgSystem compile order is now a pure function of content (GUID tiebreak in the X/Y child sort, sorted GUID map) |
Four sheet-imported dialogues that flipped node order on every save are now byte-stable across resaves; a stale sidecar can no longer reach the remote |
| 2026-08-20 | Registry gains UMontageTableValidator; CI/manual path list corrected to the three roots actually swept; canonical commandlet table added |
Combo-table contracts documented; the doc no longer under-reports the sweep scope; one place to find every -run= entry point |
| 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 |