0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI RELIC_SYSTEM
UTC 00:00:00
◀ RETURN
RELIC_SYSTEM.md 3957 words ~18 min read Updated 2026-07-03

CRADL Relic System

Companion to ARCHITECTURE.md, PILOT_SYSTEM.md, QUEST_SYSTEM.md, and VOUCHER_SYSTEM.md. This document is the contract the relic system must satisfy — its persistence shape, the two reward pathways that grant it, the single-active selection surface, the per-cockpit display resolution, and its deliberate non-participation in readiness/block gating and the stat pipeline. Implementation patterns, balance numbers, and per-relic authoring briefs live elsewhere; what's here does not change without a deliberate edit to this file.

North Star

A relic is a permanent, account-scoped cosmetic collectible. Its only job is to appear inside the local player's pilot picture-in-picture (PIP) — an authored "scene" actor staged next to the cockpit and pilot in the off-screen pocket world (PILOT_SYSTEM.md "Scene Composition"). It is a display trophy, in the OSRS lineage of mounted, earned-and-shown collectibles, not a gameplay modifier: a relic carries no GameplayEffect, no stats, and no in-world manifestation.

Relics reuse every unlock-axis and cosmetic-rig pattern the loadout ("ship") and loadout-modifier ("pilot") systems already established — replicated owner-only unlock set, authority-only grant APIs, a save-profile round-trip, a UPrimaryDataAsset + soft-class authorable actor, a terminal-driven eager-apply swap ability, and a subsystem-owned local rig spawned into the pocket world. It adds a display axis, not a foundation. Where it diverges from its siblings, it diverges deliberately — narrower replication (HUD-local, never broadcast to peers), no stat-pipeline participation, and no PIP readiness/block gating on swap — and each divergence is called out below.

Quick Reference

Topic Answer Section
Theme term Relic — generic C++ surface; content DisplayNames carry the motif (per THEME.md) Definition & Authorable Actor
Definition URelicDefinition : UPrimaryDataAsset (new), PrimaryAssetType "RelicDefinition" Definition & Authorable Actor
Authorable scene ARelicVisualsActor : AVisualsRigActor (new), soft-class on the definition Definition & Authorable Actor
Persistent state URelicComponent (new) on ACradlPlayerState: UnlockedRelicIds + single ActiveRelicId Persistent State & Replication
Save UCradlPlayerProfile.UnlockedRelicIds + .ActiveRelicId (new); bump CRADL_SAVE_VERSION 14 → 15 Persistent State & Replication
Replication Both fields Replicated COND_OwnerOnly — narrower than ActiveLoadoutId (no in-world rig → peers never read it) Persistent State & Replication
Grant API URelicComponent::GrantRelic(FPrimaryAssetId) (new) — idempotent set membership Persistent State & Replication
Quest reward EQuestRewardType::Relic (new) + FQuestReward::RelicAssetId (new); DeliverReward switch case Reward Pathway — Quests
Voucher EVoucherKind::Relic (new) + UVoucherDefinition::RelicAssetId (new); waste-gated like Loadout Reward Pathway — Vouchers
Selection UI URelicSlotPanel (new) BindWidget on ULoadoutSwapWidget; sentinel "no relic" row; eager-apply Selection UI & Swap Ability
Swap URelicSwapAbility (new) (LocalPredicted), trigger Action.Trigger.RelicSwap (new), gated by Action.Modal.LoadoutSwap Selection UI & Swap Ability
Display location IRelicDisplayProvider (new) on the cockpit rig → Cradl::Visuals::ResolveRelicAnchor (new) PIP Display & Cockpit Anchor
Swap feel Relic swap is silent — no EnterNotReady, no SetVisualBlocked, no pilot-view eject PIP Display & Cockpit Anchor
Validators CradlRelicDefinitionValidator (new); extend CradlVoucherDefinitionValidator (+ quest reward variant) Validators

Definition & Authorable Actor

Rule. A relic is authored as URelicDefinition : UPrimaryDataAsset (new), sibling to ULoadoutDefinition and ULoadoutModifierDefinition, living under Source/CRADL/Loadout/ for proximity to its selection siblings. It overrides GetPrimaryAssetId() to return type "RelicDefinition" (mirroring ULoadoutModifierDefinition::GetPrimaryAssetId). Fields:

  • DisplayName (FText) and Icon (TSoftObjectPtr<UTexture2D>) — presentation, mirroring the modifier definition.
  • VisualsActor (TSoftClassPtr<ARelicVisualsActor>) — required (a relic with no scene is meaningless; the validator flags null). The authorable scene actor, soft-loaded async and spawned local-only — exactly the role ULoadoutModifierDefinition::VisualsActor plays for the pilot rig.
  • SkillRequirements (TArray<FSkillRequirement>) and PrerequisiteQuests (TArray<FName>) — optional selection gates, mirroring the modifier definition's axes (empty = no gate). Enforced on the swap-to-active path only and save-restore exempt (see Selection). Carried for forward parity; v1 content typically leaves both empty.

ARelicVisualsActor : AVisualsRigActor (new) is a thin subclass mirroring ALoadoutPreviewVisualsActor — a stable parent type for content BPs that compose the relic "scene" (StaticMesh, Niagara, etc.) WYSIWYG in the BP viewport. It sets bReplicates = false in its constructor; it is never attached to the gameplay pawn and never streamed from a placed level. Theme-agnostic: the class name and C++ surface stay generic; the relic motif lives in BP subclass names and content under /Game/.

Why. Relics need a soft-loadable authorable actor class plus stable presentation metadata — that is precisely what a UPrimaryDataAsset with a TSoftClassPtr field already gives loadouts and modifiers. The user's prompt fixed this shape, and grounding confirmed no cheaper container fits (a DataTable row can't carry a per-relic soft actor class the asset manager streams independently). The "RelicDefinition" PrimaryAssetType is the stable identity used on the wire for swaps and as the payload of both reward pathways.

Implementation surface. - Files: Source/CRADL/Loadout/RelicDefinition.h (new), Source/CRADL/Loadout/RelicVisualsActor.h/.cpp (new). - Classes: URelicDefinition (new), ARelicVisualsActor (new). - Base: AVisualsRigActor (existing).

Footguns. - Relics apply zero GameplayEffects. Do not give URelicDefinition a StatsEffect/InnateStatsEffect field — that is the single sharpest line between a relic and a modifier. Consequently the entire STAT_PIPELINE.md surface and the Effect.Persistent respawn allowlist (project_persistent_effect_allowlist) are N/A to relics; there is no GE to route through MakePersistentEffectSpec and nothing for the death pipeline to strip. - ARelicVisualsActor is simple by design — no VSC reaction firehose (XP/level/combat/health), unlike AModifierVisualsActor. A reaction surface is an explicit Open Question, not an oversight.

Related. ARCHITECTURE.md decision #12 (presentation flows as structs, never raw ids to BP); THEME.md (generic C++ surface).


Persistent State & Replication

Rule. A new URelicComponent (new) on ACradlPlayerState owns relic state, sibling to ULoadoutComponent and ULoadoutModifierComponent:

  • UnlockedRelicIds (TArray<FPrimaryAssetId>) — the unlock set, mirroring ULoadoutComponent's UnlockedLoadouts. Replicated, COND_OwnerOnly, with an OnRep_UnlockedRelicIds that broadcasts an OnUnlocksChanged delegate (UI binds it).
  • ActiveRelicId (FPrimaryAssetId) — the single active selection (invalid id = "no relic shown"). Replicated, COND_OwnerOnly, with an OnRep_ActiveRelicId that broadcasts an OnRelicChanged delegate carrying the new id (the PIP subsystem and UI bind it).

Authority-only mutation APIs, mirroring the loadout/modifier components: - GrantRelic(FPrimaryAssetId) — idempotent; appends to UnlockedRelicIds only if absent, then broadcasts. Mirrors ULoadoutComponent::GrantLoadoutId. - IsRelicUnlocked(FPrimaryAssetId) const — read for the voucher waste gate and the UI. - SetActiveRelic(FPrimaryAssetId, bool bCheckRequirements = true) — validates the relic is unlocked (and, when bCheckRequirements, the definition's SkillRequirements/PrerequisiteQuests via USkillsComponent::MeetsRequirements + CradlQuestGate::MeetsPrerequisites); sets ActiveRelicId and broadcasts OnRelicChanged. An invalid id is the explicit "clear / no relic" path and skips the unlock check. - Restore + capture helpers for the save round-trip (bCheckRequirements = false on restore).

UCradlPlayerProfile (new fields): UnlockedRelicIds (TArray<FPrimaryAssetId>) and ActiveRelicId (FPrimaryAssetId), placed beside the existing UnlockedLoadoutIds/ActiveLoadoutId/UnlockedModifierInstances/ActiveModifierAssignments. Bump CRADL_SAVE_VERSION 14 → 15; additive — older saves load both empty (no relic), exactly as a fresh character does. UCradlSaveSubsystem captures both on save and restores them on load — unlock set before active selection, mirroring the loadout ordering, and alongside the modifier restore step.

Why. Loadout and pilot each got a dedicated per-axis component; a third cosmetic axis follows the same convention rather than overloading either sibling. Folding relics into ULoadoutModifierComponent would graft a GE-less, slot-less, single-active axis onto a multi-slot GE-bearing one; folding into ULoadoutComponent would couple cosmetic selection to bag/equipment reshape. A clean component keeps each axis's authority and replication answer independent.

Replication audit (mandatory, P2P). - UnlockedRelicIds — server-authoritative (grants run on authority via the quest/voucher abilities). COND_OwnerOnly: only the owning client's selection UI and the owner-predicted redeem/swap abilities ever read it; no non-owning peer needs it. Matches UnlockedLoadouts. - ActiveRelicId — server-authoritative + persisted, but replicated COND_OwnerOnly, which is deliberately narrower than ULoadoutComponent::ActiveLoadoutId (replicated to all because it also drives the in-world ship rig observed by peers). A relic has no in-world manifestation — it renders only in the owning player's local HUD PIP — so a remote peer can never observe it and must not receive it. This threads CLAUDE.md's "never replicate cosmetic-only state (local UI is client-side only)" correctly: the relic actor is local-only (bReplicates = false, spawned client-side); only the authoritative selection key replicates, and only to the one client that renders it (the server is the authority and persistence source, so the key cannot be pure client-local without a bespoke save-sync path). - The COND_OwnerOnly "empty component on a non-owning peer" footgun (feedback_cond_owneronly_peer_fallback_wrong) does not bite here: relic state is read only on the authority and the owning client (swap is LocalPredicted owner-run; waste gate runs owner + authority). No gate is ever evaluated against a relic component on a non-owning peer.

Footguns. - Restore must run with bCheckRequirements = false and never strand a saved selection — a relic granted before a requirement was added stays selectable, matching the loadout/modifier save-restore exemption. - Guest/host-seeded players: per project_guest_progression_v1_deferred, cover both LoadPlayer outcomes — a fresh-seeded guest simply lands with empty UnlockedRelicIds/invalid ActiveRelicId (no relic), which is the correct default.

Related. Reward Pathway — Quests, Reward Pathway — Vouchers; UCradlPlayerProfile.


Reward Pathway — Quests

Rule. Add a Relic variant to the quest reward pipeline, mirroring the existing idempotent Loadout/SpellbookUnlock kinds (not the non-idempotent LoadoutModifier):

  • EQuestRewardType::Relic (new) on the enum in FQuestReward.
  • FQuestReward::RelicAssetId (new) (FPrimaryAssetId), gated by meta=(EditCondition="RewardType==EQuestRewardType::Relic", EditConditionHides) per the existing flat-discriminator convention.
  • FQuestRewardManifestEntry::RelicAssetId (new) in Source/CRADL/Quests/QuestPersistentTypes.h, and a Relic case in QuestRewardIdentityMatches comparing RelicAssetId (binary identity, no value delta — same as Loadout).
  • A case EQuestRewardType::Relic: in UQuestComponent::DeliverReward dispatching to URelicComponent::GrantRelic(RelicAssetId) and recording the entry.

Why. The reward manifest is sized for multiple idempotent grant kinds; Loadout already demonstrates the exact shape (one enum value, one FPrimaryAssetId field, one idempotent grant API, manifest identity = the id). Reconciliation-by-diff on launch then works automatically: a relic reward newly added to a UQuestDefinition grants on the next startup; an existing one is a no-op; a removed one leaves the player's relic alone (the manifest is a ledger of what was applied, never reclaimed).

Implementation surface. - Files: Source/CRADL/Quests/QuestReward.h, Source/CRADL/Quests/QuestPersistentTypes.h, Source/CRADL/Quests/QuestComponent.cpp. - Symbols: EQuestRewardType::Relic (new), FQuestReward::RelicAssetId (new), FQuestRewardManifestEntry::RelicAssetId (new), QuestRewardIdentityMatches (extend), UQuestComponent::DeliverReward (extend).

Footguns. - Relics, like Spellbook/Loadout, have no overflow surface — they never enter the item PendingRewards ladder (SeatItemViaLadder). A missing URelicComponent is an init error (log at Warning per feedback_log_level_warning_for_diagnostics), not a deferral. - Idempotency lives at the grant API (GrantRelic appends only if absent) and the manifest diff — do not add a second dedup layer at the UI.

Related. QUEST_SYSTEM.md "Reward Pipeline"; Persistent State & Replication.


Reward Pathway — Vouchers

Rule. Add a 4th EVoucherKind variant, mirroring the Loadout variant exactly (idempotent, waste-gated):

  • EVoucherKind::Relic (new) on the enum in UVoucherDefinition.
  • UVoucherDefinition::RelicAssetId (new) (FPrimaryAssetId), gated by meta=(EditCondition="Kind==EVoucherKind::Relic", EditConditionHides).
  • In URedeemVoucherAbility::ActivateAbility: a Relic case in the waste-gate switch (reject with a player-facing toast if URelicComponent::IsRelicUnlocked(RelicAssetId) — relics are idempotent, so already-owned is a waste case, exactly like Loadout/Spellbook) and a Relic case in the grant-dispatch switch (URelicComponent::GrantRelic(RelicAssetId) after the unconditional decrement).

No new tags or item-menu changes: a relic voucher is an ordinary voucher item, marked by the existing Item.Voucher tag ⇔ FItemRow::Voucher reference and redeemed by the existing Action.Trigger.Item.RedeemURedeemVoucherAbility path (VOUCHER_SYSTEM.md "Context Menu").

Why. The voucher dispatch is a closed enum-variant loop that already scales to a 4th kind by literal extension (grounding confirmed no abstraction refactor is warranted while the new kind mirrors an existing one). Relic matches Loadout's contract — FPrimaryAssetId payload, idempotent grant, binary waste gate — so it slots into both switches and the validator with no structural change.

Implementation surface. - Files: Source/CRADL/Inventory/VoucherDefinition.h, Source/CRADL/Abilities/RedeemVoucherAbility.cpp. - Symbols: EVoucherKind::Relic (new), UVoucherDefinition::RelicAssetId (new), URedeemVoucherAbility::ActivateAbility (extend both switches).

Footguns. - Vouchers are deliberately not unified with the quest pipeline (VOUCHER_SYSTEM.md "Why not the quest pipeline") — the two reach GrantRelic independently. Do not extract a shared relic-grant orchestrator; add the case to each. - The Consumable/Voucher mutual-exclusion invariant in CradlItemTableValidator is unchanged — a relic voucher is a voucher, never also a consumable.

Related. VOUCHER_SYSTEM.md; Validators.


Selection UI & Swap Ability

Rule. The player picks the single active relic at the existing loadout terminal, through a new URelicSlotPanel (new) BindWidget on ULoadoutSwapWidget, mirroring the pilot panel (ULoadoutModifierSlotPanel):

  • URelicSlotPanel is a UUserWidget with a UCommonButtonGroupBase + bSuppressSelectionDispatch eager-apply list, a leading sentinel "no relic" row (invalid id → clears the selection), and rows of class URelicEntryListItem (new) fed FRelicPresentationData (new). Selecting a row dispatches immediately; there is no Apply button.
  • FRelicPresentationData is built C++-side with pre-resolved DisplayName, Icon, requirement arrays, and bIsActive — the WBP never resolves an FPrimaryAssetId (ARCHITECTURE.md #12; feedback_presentation_struct_resolve_tags).
  • ULoadoutSwapWidget declares UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<URelicSlotPanel> RelicPanel;, configures it in RefreshRelicPanel(), and binds its applied-toast delegate in NativeConstruct / unbinds in NativeDestruct (feedback_bind_child_delegate_in_nativeconstruct — modal pages are reused). Unlike the modifier panel, the relic panel is not loadout-gated (relics are independent of the active loadout); it is shown whenever the player has ≥1 unlocked relic, collapsed otherwise.
  • Row selection calls ASC->HandleGameplayEvent(Action.Trigger.RelicSwap, payload) with a URelicSwapRequest (new) carrying the terminal + relic id — mirroring ULoadoutModifierSlotPanel::DispatchModifierSwap and ULoadoutModifierSwapRequest.
  • URelicSwapAbility (new) (NetExecutionPolicy = LocalPredicted, ActivationRequiredTags = Action.Modal.LoadoutSwap) mirrors ULoadoutModifierSwapAbility: authority re-validates (alive; unlocked; optional requirements), then branches — valid id → SetActiveRelic(id), invalid id → SetActiveRelic(invalid) (clear).

Why. The pilot panel is the proven template for a single-active, sentinel-led, eager-apply selection of an unlocked cosmetic, hosted in the same terminal modal. Reusing its shape (button group churn suppression, sentinel-clear, presentation-struct hand-off) keeps the relic surface consistent and avoids re-deriving the CommonUI selection mechanics. Reusing the modal gate (Action.Modal.LoadoutSwap) means relic dispatch fails cleanly outside the terminal, identical to the loadout/pilot verbs.

Implementation surface. - Files: Source/CRADL/UI/RelicSlotPanel.h/.cpp (new), Source/CRADL/UI/RelicEntryListItem.h (new), Source/CRADL/UI/RelicPresentation.h (new), Source/CRADL/UI/LoadoutSwapWidget.h (extend), Source/CRADL/Abilities/RelicSwapAbility.h/.cpp (new). - Symbols: URelicSlotPanel, URelicEntryListItem, FRelicPresentationData, URelicSwapAbility, URelicSwapRequest — all (new); ULoadoutSwapWidget::RelicPanel + RefreshRelicPanel() (new).

Replication audit. The swap is LocalPredicted but the SetActiveRelic mutation is authority-only (HasAuthority-gated); the result flows back via ActiveRelicId's COND_OwnerOnly OnRep. Prediction is vestigial (the only client-side payoff is the local relic respawn, which the OnRep would drive anyway) but keeps the dispatch path identical to the sibling verbs.

Footguns. - Avoid Slot as a local/parameter name anywhere in URelicSlotPanel (C4458 shadows UWidget::Slotfeedback_slot_shadows_uwidget); use RelicSlot/SlotData. - New panel UPROPERTYs the designer tunes (ListItemClass, list container) are EditAnywhere, not EditDefaultsOnly (feedback_umg_widget_uproperty_edit_specifier). - If a relic row is ever a CommonUI toggle rather than a list button, bind OnIsSelectedChanged, not OnClicked (reference_commonui_toggle_bind_selectedchanged).

Related. PIP Display & Cockpit Anchor; ALoadoutTerminal.


PIP Display & Cockpit Anchor

Rule. The relic renders as a local-only actor in the pilot PIP, owned and staged by ULoadoutPreviewSubsystem as a third rig pipeline alongside the cockpit and pilot, with two deliberate divergences from those siblings: a per-cockpit attach anchor, and no readiness/block gating on swap.

Display location resolution. Each cockpit BP optionally authors where its relic sits via IRelicDisplayProvider (new) — a preview-only UInterface modeled exactly on IPreviewCameraProvider:

  • IRelicDisplayProvider::GetRelicAnchor() const (BlueprintNativeEvent, default nullptr) returns the USceneComponent the relic attaches to.
  • Cradl::Visuals::ResolveRelicAnchor(AActor* Cockpit) (new) dispatches: interface override if implemented and non-null → else the first USceneComponent on the cockpit carrying the RelicAnchor component tag (the "drop a component, tag it, done" convenience that mirrors the camera provider's "drop a Camera and it's picked up") → else nullptr.
  • A nullptr anchor means the relic is not shown for that cockpit (a cockpit that authors no anchor simply displays no relic). The author is responsible for the anchor playing nice with their scene (per the prompt).

The relic actor is spawned via the existing SpawnRigIntoStaging contract (RF_Transient, visible, never replicated, at GPreviewStagingSpawnPoint), then AttachToComponent(Anchor, KeepRelativeTransform) and AddActorToAllowlist so the scene capture renders it. Its anim tick pauses with PIP visibility via the existing SetRigTicksEnabled path.

Driver key + spawn pipeline. A new pipeline mirrors the cockpit two-stage async load + supersede guard: - Bind URelicComponent::OnRelicChanged in BindDriverKeysHandleRelicChanged(FPrimaryAssetId)RequestRelic (resolve URelicDefinition, async if not resident) → OnRelicClassResolved (resolve URelicDefinition::VisualsActor soft class) → SpawnRelicRigClearRelicRig on swap/clear. - New members mirror the cockpit set: TWeakObjectPtr<ARelicVisualsActor> RelicRig, TSharedPtr<FStreamableHandle> RelicLoadHandle, FPrimaryAssetId ActiveRelicIdLocal (supersede + same-key short-circuit), TSoftClassPtr<ARelicVisualsActor> PendingRelicClass, FDelegateHandle RelicChangedHandle.

Cockpit dependency / re-anchor. The relic anchors against the current cockpit rig, so the relic refresh is driven from two places: a relic-key change (HandleRelicChanged), and cockpit (re)spawn — when the cockpit changes on a loadout swap, the relic must re-anchor to the new cockpit. SpawnCockpitRig completion therefore kicks a relic refresh against the freshly-spawned anchor, and HandleRelicChanged defers if no cockpit rig is present yet (the cockpit-spawn completion picks up the active relic), mirroring the subsystem's existing seed-vs-broadcast ordering.

Silent swap. HandleRelicChanged and ClearRelicRig never call EnterNotReady, MarkCockpitSettled/MarkPilotSettled, or SetVisualBlocked. A relic swap inside an already-ready scene is a quiet despawn/respawn: the old relic actor is destroyed (its dead weak ref compacts out of the allowlist on the next CaptureNow), the new one spawns and attaches, and the PIP keeps rendering with no placeholder and no pilot-view eject. The readiness/block gates remain reserved for cockpit/pilot rebuilds, which do tear down the framed subject.

Why. The subsystem already spawns two local rigs from replicated driver keys with two-stage async loads and a supersede guard; a third pipeline fits without reshaping the core (grounding found no structural refactor needed). IPreviewCameraProvider already established the precedent of a preview-specific sibling to the general in-world IVisualsAnchorProvider — so the relic socket, which lives on a preview cockpit, gets its own preview-specific interface rather than overloading the in-world anchor surface (whose driver-mesh fallback and pawn implementers do not map to an off-screen cockpit). The silent-swap divergence is the user's explicit requirement and is cheap precisely because readiness gating is opt-in per handler, not global.

Implementation surface. - Files: Source/CRADL/Visuals/RelicDisplayProvider.h/.cpp (new), Source/CRADL/Visuals/LoadoutPreviewSubsystem.h/.cpp (extend). - Symbols: IRelicDisplayProvider / URelicDisplayProvider (new), Cradl::Visuals::ResolveRelicAnchor (new), the relic pipeline functions + members on ULoadoutPreviewSubsystem (new), the RelicAnchor component-tag FName convention (new — an FName component tag, not a gameplay tag).

Footguns. - Don't reach for IVisualsAnchorProvider for the relic socket — see Why. It is the in-world rig-attach surface (implemented on ACradlCharacter) with a driver-mesh fallback; reusing it would conflate two roles. - Spawn-order trap (project_camera_bounce_respawn): the relic depends on the cockpit anchor existing. Anchoring before the cockpit settles yields a null anchor → no relic; the cockpit-spawn-completion refresh is the load-bearing fix, not an afterthought. - Pocket-world hygiene (project_pocketworlds_vendoring): the relic actor is RF_Transient + bReplicates = false and left visible (the capture's show-only list renders allowlisted actors; SetActorHiddenInGame would hide it from the capture too). - Do not broadcast or networked-spawn the relic actor — it is per-local-player, rebuilt from the COND_OwnerOnly key (see replication audit).

Related. PILOT_SYSTEM.md "Scene Composition", "Initialization & Swap Gating", "External Block / Feed-Offline"; IPreviewCameraProvider.


Tag Taxonomy

Per feedback_gameplay_tag_decl_minimal, the .ini (Config/DefaultGameplayTags.ini) is authoritative; only tags referenced by name in C++ get a UE_DECLARE_GAMEPLAY_TAG_EXTERN in Source/CRADL/CradlGameplayTags.h.

New (C++-declared, referenced by name): - Action.Trigger.RelicSwapGameplayEvent trigger for URelicSwapAbility, fired by URelicSlotPanel. Mirrors Action.Trigger.LoadoutModifierSwap.

Reused (no new tag): - Action.Modal.LoadoutSwapActivationRequiredTags gate on URelicSwapAbility (the relic panel lives in the same terminal modal). - Action.Trigger.Item.Redeem, Item.Voucher, GameplayCue.Item.Redeem — the relic voucher rides the existing voucher path unchanged.

Not a gameplay tag: the RelicAnchor cockpit attach point uses an FName component tag, resolved by Cradl::Visuals::ResolveRelicAnchor's fallback — distinct from the gameplay-tag namespace above.

Forward Code References

Path Contents
Source/CRADL/Loadout/RelicDefinition.h URelicDefinition
Source/CRADL/Loadout/RelicVisualsActor.h ARelicVisualsActor
Source/CRADL/Loadout/RelicComponent.h URelicComponent (on ACradlPlayerState)
Source/CRADL/Abilities/RelicSwapAbility.h URelicSwapAbility, URelicSwapRequest
Source/CRADL/UI/RelicSlotPanel.h URelicSlotPanel
Source/CRADL/UI/RelicEntryListItem.h URelicEntryListItem
Source/CRADL/UI/RelicPresentation.h FRelicPresentationData
Source/CRADL/Visuals/RelicDisplayProvider.h IRelicDisplayProvider, Cradl::Visuals::ResolveRelicAnchor
Source/CRADLEditor/Validators/CradlRelicDefinitionValidator.h CradlRelicDefinitionValidator
(extend) Source/CRADL/SaveGame/CradlPlayerProfile.h UnlockedRelicIds, ActiveRelicId, CRADL_SAVE_VERSION 15
(extend) Source/CRADL/Quests/QuestReward.h, QuestPersistentTypes.h, QuestComponent.cpp EQuestRewardType::Relic + RelicAssetId + dispatch
(extend) Source/CRADL/Inventory/VoucherDefinition.h, Source/CRADL/Abilities/RedeemVoucherAbility.cpp EVoucherKind::Relic + RelicAssetId + dispatch
(extend) Source/CRADL/Visuals/LoadoutPreviewSubsystem.h relic pipeline
(extend) Source/CRADL/UI/LoadoutSwapWidget.h RelicPanel BindWidget + RefreshRelicPanel()

Validators

Per CLAUDE.md "validators shadow the runtime structs":

  • CradlRelicDefinitionValidator (new) under Source/CRADLEditor/Validators/, mirroring CradlLoadoutModifierDefinitionValidator: DisplayName non-empty; Icon set/resolves; VisualsActor non-null and resolves (required, unlike the optional loadout/modifier rig); SkillRequirements entries resolve.
  • Extend CradlVoucherDefinitionValidator: a Relic case requiring RelicAssetId valid and PrimaryAssetType == "RelicDefinition" (the string-literal pattern the Loadout/LoadoutModifier cases already use). Resolve any tags via RequestGameplayTag(string), not CradlTags::X — native tag symbols don't cross-module link into CRADLEditor (reference_native_tag_no_cross_module_export).
  • Quest reward validation: if/where FQuestReward variants are validated (a deeper quest-reward validator beyond CradlQuestDefinitionValidator's current task checks), add the Relic case (RelicAssetId valid + PrimaryAssetType == "RelicDefinition") in lockstep. See Open Questions #3.

Open Questions

  1. ARelicVisualsActor reaction surface. v1 is a thin display actor with no reaction BIEs. Should it gain a minimal NotifyActivated() (an intro flourish on spawn/re-anchor), or the fuller AModifierVisualsActor reaction set (XP/level/combat/health)? Default: thin; add NotifyActivated() only if content needs an entrance.
  2. Relic panel visibility when zero unlocked. Collapsed until ≥1 relic is unlocked (the proposed rule), or always shown with only the "no relic" sentinel for discoverability? A UX call.
  3. Quest reward-variant validator coverage. Does a validator currently check FQuestReward per-variant required fields, or only UQuestDefinition tasks? If reward-variant validation doesn't exist yet, the Relic case is deferred with the rest rather than added now — confirm during phasing.
  4. Relic selection requirements in v1. URelicDefinition carries optional SkillRequirements/PrerequisiteQuests for forward parity, enforced on the swap path. Whether v1 content actually uses any gate (vs. all relics being ungated-once-unlocked) is a content decision, not a code one.

Contract drafted at RELIC_SYSTEM.md. Review and tell me when aligned, or run /design-system Relic again with iteration feedback. Run /design-system --phase=derive-implementation Relic to derive the phased implementation doc from it.