Skip to content

Content Validation

Summary: One validation spine gates content: UEditorValidatorBase validators run on save in the editor, and the same checks run headlessly via the EternalValidation commandlet — invoked by CI on every PR, by the pre-push hook when Content/** changes, or manually via Tools/run_validation.ps1. Broken data assets fail loudly at save time, push time, and PR time instead of at runtime.

Table of Contents


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

Tools\run_validation.ps1 [-Paths /Game/DataAssets] [-Strict] [-NoBuild] [-EnginePath <engine root>]

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

  1. Put the logic in a *ValidationCore (or extend an existing one) — plain functions over the asset's data.
  2. Spec the core under Source/ProjectEternalEditor/Private/Tests/.
  3. Wrap it in a UEditorValidatorBase subclass in Source/ProjectEternalEditor/*/Validation/.
  4. 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.cpp pins ActionTag on both enemy bases and each shipped base's EMontageRoute, so a route flipped to its None default (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
  • Testing — spec coverage for validation cores; ProjectEternal.Content.Dungeon.GenerationRegression exercises 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