CRADL Relic Implementation
Companion to RELIC_SYSTEM.md (the contract), PILOT_SYSTEM.md / PILOT_IMPLEMENTATION.md (the PIP pipeline this extends), QUEST_SYSTEM.md, VOUCHER_SYSTEM.md, and ARCHITECTURE.md. This doc tracks the build order for v1 relics: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what a relic is; this doc says what we build first, what depends on what, and how we know each step works.
This is extending, not greenfield: every system the relic build touches — the loadout/PIP subsystem (ULoadoutPreviewSubsystem), the save round-trip (UCradlSaveSubsystem), the quest reward pipeline (UQuestComponent), and the voucher redeem path (URedeemVoucherAbility) — is shipped code today. Relics add a third cosmetic axis alongside loadout and pilot; the build mirrors the patterns those axes already established. There are no pending-phase prerequisites in other docs; every dependency is live code.
Conventions
- Phase status legend:
[ ]not started ·[~]in progress ·[x]done ·[!]blocked / deferred. - Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
- Cheat commands: test fixtures land under
UCradlDebugComponent(the controller's dev-only console host) exec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. - Per CLAUDE.md "validators in lockstep": any phase that touches
URelicDefinition,FQuestReward, orUVoucherDefinitionupdates the matching validator under Source/CRADLEditor/Validators/ in the same change — not a follow-up (feedback_no_clean_up_later). - Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented
Build.batcall (UE: Build Editor (Development), via the PowerShell call operator) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job. - Per CLAUDE.md replication-first: relic state replicates
COND_OwnerOnlyand the relic actor never replicates — see the contract's replication audit. Every phase that adds a server-mutated field restates its replication answer in Footguns.
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag, Definition & Visuals Scaffolding | [x] |
All later phases |
| 1 | Persistence Spine — URelicComponent + Save round-trip |
[x] |
2, 3, 4, 5 (every grant/select/display verb) |
| 2 | Quest Reward Pathway | [x] |
Parallel-able with 3 |
| 3 | Voucher Reward Pathway | [x] |
Parallel-able with 2 |
| 4 | Selection UI & Swap Ability | [x] |
Parallel-able with 5 (consumes Phase 1) |
| 5 | PIP Display & Cockpit Anchor | [x] |
Closes the loop (parallel-able with 4) |
After Phase 1, phases 2–5 fan out: each depends only on Phase 0 + Phase 1 (and the Phase 1 cheat fixtures), not on each other.
Phase 0 — Tag, Definition & Visuals Scaffolding
Goal. A compile-clean codebase with the relic-only types every later phase references: the swap trigger tag, the URelicDefinition DataAsset (authorable in the editor), the ARelicVisualsActor parent class (subclassable in BP), and the definition validator. No behavior — nothing grants, selects, or renders a relic yet. After this phase you can author a URelicDefinition asset and a BP relic-scene actor and have the validator gate them.
Rationale. Phase 0 is always tag + data scaffolding. Every downstream phase names these types: the persistence spine stores FPrimaryAssetIds that resolve to URelicDefinition; both reward pathways carry a RelicAssetId pointing at one; the PIP pipeline spawns the definition's VisualsActor. Landing them first (with their validator) unblocks all of phases 1–5 and lets content authoring begin in parallel with the C++ build.
Tasks.
- [x] Relic tag — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h:
- [x]
Action.Trigger.RelicSwap(both —.iniis authoritative;UE_DECLARE_GAMEPLAY_TAG_EXTERNin the header becauseURelicSlotPanel/URelicSwapAbilityreference it by name, mirroringAction_Trigger_LoadoutModifierSwap; perfeedback_gameplay_tag_decl_minimal). - [x]
URelicDefinition— Source/CRADL/Loadout/RelicDefinition.h (new): - [x]
URelicDefinition : UPrimaryDataAsset, sibling toULoadoutModifierDefinition. - [x]
GetPrimaryAssetId()override returning type"RelicDefinition"(mirrorULoadoutModifierDefinition::GetPrimaryAssetId). - [x]
DisplayName(FText),Icon(TSoftObjectPtr<UTexture2D>). - [x]
VisualsActor(TSoftClassPtr<ARelicVisualsActor>) — required (validator flags null). - [x]
SkillRequirements(TArray<FSkillRequirement>),PrerequisiteQuests(TArray<FName>) — optional selection gates, carried for forward parity (v1 content typically leaves both empty). - [x] No
StatsEffect/InnateStatsEffectfield — the sharpest line between a relic and a modifier (contract Footgun). - [x]
ARelicVisualsActor— Source/CRADL/Loadout/RelicVisualsActor.h/.cpp(new): - [x]
ARelicVisualsActor : AVisualsRigActor(Source/CRADL/Visuals/VisualsRigActor.h), thin subclass mirroringALoadoutPreviewVisualsActor. - [x] Constructor sets
bReplicates = false. - [x] No VSC reaction firehose (XP/level/combat/health), unlike
AModifierVisualsActor. A reaction surface is Open Question #1 — built thin in v1. - [x] Validator — Source/CRADLEditor/Validators/CradlRelicDefinitionValidator.h/
.cpp(new): - [x]
CradlRelicDefinitionValidatormirroringCradlLoadoutModifierDefinitionValidator:DisplayNamenon-empty;Iconset/resolves;VisualsActornon-null and resolves (required — stricter than the optional loadout/modifier rig);SkillRequirementsentries resolve. - [x] If the validator references any gameplay tag, resolve via
RequestGameplayTag(string), notCradlTags::X(native symbols don't cross-module link into CRADLEditor —reference_native_tag_no_cross_module_export). (The relic validator likely needs no tag lookup, but the rule stands if one is added.)
Verification.
- Compile clean (Claude builds via
Build.bat; links exit-0). - In-editor (user): create a
URelicDefinitionasset → validation fails whileVisualsActor/DisplayName/Iconare unset, passes once all three are filled and a BP subclass ofARelicVisualsActoris assigned. - A BP author can subclass
ARelicVisualsActorand compose a scene (StaticMesh/Niagara) WYSIWYG in the viewport.
Exits. Phases 1–5 now have: a stable "RelicDefinition" PrimaryAssetType key (Phase 1 persistence, Phases 2/3 reward payloads), an authorable definition with a soft VisualsActor (Phase 5 spawn), and the Action.Trigger.RelicSwap tag (Phase 4 dispatch).
Footguns.
- Relics apply zero GameplayEffects — adding a stats field here would silently turn a relic into a modifier and drag in the entire STAT_PIPELINE.md surface + the
Effect.Persistentrespawn allowlist (project_persistent_effect_allowlist), both N/A to relics. ARelicVisualsActorstaysbReplicates = false— it is local-only, never on the gameplay pawn, never level-streamed.
Phase 1 — Persistence Spine (URelicComponent + Save round-trip)
Goal. Relic state exists, replicates correctly, and survives a save/reload. A new URelicComponent on ACradlPlayerState owns UnlockedRelicIds + ActiveRelicId with authority-only mutators and COND_OwnerOnly replication; UCradlPlayerProfile carries both fields; UCradlSaveSubsystem captures and restores them. Cheat commands drive the whole spine. No reward pathway, UI, or render yet — but every verb in phases 2–5 calls into this component.
Rationale. This is the spine the contract's "one spine before its verbs" rule demands: the grant pathways (2, 3) call GrantRelic; the swap surface (4) calls SetActiveRelic/IsRelicUnlocked and binds OnUnlocksChanged; the PIP pipeline (5) binds OnRelicChanged. Building the component, its replication, and its save round-trip first — with cheat fixtures — means phases 2–5 each have a real, persisted, observable backing store from day one. Loadout and pilot each got a dedicated per-axis component; this is the third, following the same convention rather than overloading either sibling.
Tasks.
- [x]
URelicComponent— Source/CRADL/Loadout/RelicComponent.h/.cpp(new): - [x] Declare on
ACradlPlayerStatebesideLoadoutComponent/LoadoutModifierComponent, with aGetRelicComponent()getter (mirror the existingGetLoadoutComponent()/GetLoadoutModifierComponent()accessors). - [x]
UnlockedRelicIds(TArray<FPrimaryAssetId>) —UPROPERTY(ReplicatedUsing=OnRep_UnlockedRelicIds);OnRepbroadcastsOnUnlocksChanged. - [x]
ActiveRelicId(FPrimaryAssetId) —UPROPERTY(ReplicatedUsing=OnRep_ActiveRelicId);OnRepbroadcastsOnRelicChanged(FPrimaryAssetId). - [x]
GetLifetimeReplicatedProps: bothDOREPLIFETIME_CONDITION(..., COND_OwnerOnly). - [x]
OnUnlocksChanged(multicast delegate, no payload) andOnRelicChanged(carries the new id) — UI + PIP subsystem bind these. - [x]
GrantRelic(FPrimaryAssetId)— authority-only; idempotent (append toUnlockedRelicIdsonly if absent), then broadcast. MirrorULoadoutComponent::GrantLoadoutId. - [x]
IsRelicUnlocked(FPrimaryAssetId) const— read for the voucher waste gate + UI. - [x]
SetActiveRelic(FPrimaryAssetId, bool bCheckRequirements = true)— authority-only; validates unlocked, and whenbCheckRequirementsalso the definition'sSkillRequirements/PrerequisiteQuestsviaUSkillsComponent::MeetsRequirements+CradlQuestGate::MeetsPrerequisites(Source/CRADL/Quests/CradlQuestGate.h). Invalid id = explicit "clear / no relic" path, skips the unlock check. SetsActiveRelicId, broadcastsOnRelicChanged. - [x] Capture/restore helpers for the save round-trip, mirroring the loadout component's names:
CaptureUnlockedSnapshot()→TArray<FPrimaryAssetId>,CaptureActiveSnapshot()→FPrimaryAssetId,RestoreUnlocked(const TArray<FPrimaryAssetId>&). Restore-active routes throughSetActiveRelic(id, /*bCheckRequirements=*/false). - [x] Save profile — Source/CRADL/SaveGame/CradlPlayerProfile.h:
- [x]
UnlockedRelicIds(TArray<FPrimaryAssetId>) +ActiveRelicId(FPrimaryAssetId), placed besideUnlockedLoadoutIds/ActiveLoadoutId/UnlockedModifierInstances/ActiveModifierAssignments. - [x] Bump
CRADL_SAVE_VERSION14 → 15 (grounding confirmed current value is 14). Additive: older saves load both empty (no relic), identical to a fresh character. - [x] Save round-trip — Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:
- [x] In
SavePlayer, capture both alongside the loadout/modifier snapshots:Profile->UnlockedRelicIds = Relic->CaptureUnlockedSnapshot();andProfile->ActiveRelicId = Relic->CaptureActiveSnapshot();. - [x] In
LoadPlayer, restore alongside the loadout/modifier restore, unlock set before active selection:Relic->RestoreUnlocked(Profile->UnlockedRelicIds);then restore-active viaSetActiveRelic(Profile->ActiveRelicId, /*bCheckRequirements=*/false). - [x] Cover both
LoadPlayeroutcomes (project_guest_progression_v1_deferred): a fresh-seeded guest lands with empty unlocks / invalid active id — the correct "no relic" default, no special-casing. - [x] Cheat fixtures — Source/CRADL/Player/CradlDebugComponent.h/
.cpp: - [x]
Relic.Grant <PrimaryAssetId>→GrantRelic(authority).Relic.SetActive <PrimaryAssetId>→SetActiveRelic(id, /*bCheckRequirements=*/false).Relic.Clear→SetActiveRelic(invalid).Relic.ListUnlocked→ log the set. - [x] Declarations unconditional; bodies guarded
#if !UE_BUILD_SHIPPING(CLAUDE.md — never combineUFUNCTION+ the macro in the header).
Verification.
- Compile clean (Claude builds via
Build.bat). - Cheat flow (user, PIE):
Relic.Grant X→Relic.ListUnlockedshows X;Relic.Grant Xagain → still one entry (idempotent).Relic.SetActive XthenRelic.Clear→OnRelicChangedlogged each time. - Save/reload: grant + set active, save, restart PIE, load →
Relic.ListUnlockedstill shows X and the active id persisted. A pre-v15 save loads clean with empty relic state. - P2P (user, listen-server with a remote client): the remote peer's copy of the owner's
URelicComponentis empty (COND_OwnerOnly— never replicated to non-owners), and nothing reads it there.
Footguns.
- Replication answer (restated):
UnlockedRelicIds+ActiveRelicIdare server-authoritative and replicateCOND_OwnerOnly— deliberately narrower thanULoadoutComponent::ActiveLoadoutId(grounding confirmed it replicates to all, because it drives the peer-visible ship rig). A relic has no in-world manifestation, so no peer may observe it. UE 5.4DOREPLIFETIME_CONDITIONwithCOND_OwnerOnlyis the correct primitive — no loose-tag mirror is involved here (feedback_replicated_loose_tag_ue54is N/A). - The
COND_OwnerOnly"empty component on a non-owning peer" fallback footgun (feedback_cond_owneronly_peer_fallback_wrong) does not bite: relic state is read only on the authority and the owning client. Do not add any peer-side relic gate that would re-introduce it. - Restore must run with
bCheckRequirements = falseand never strand a saved selection — a relic granted before a requirement was added stays selectable (matches the loadout/modifier save-restore exemption).
Phase 2 — Quest Reward Pathway
Parallel-able with Phase 3. Depends on Phase 0 (URelicDefinition) + Phase 1 (GrantRelic).
Goal. A UQuestDefinition can carry a Relic reward that grants on quest completion, records a manifest entry, and reconciles idempotently on launch — mirroring the existing Loadout/SpellbookUnlock reward kinds. The quest reward-variant validator gates the new field in lockstep.
Rationale. "Test fixture before content" is already satisfied by Phase 1's Relic.Grant cheat; this phase wires the production grant path. The reward manifest is sized for multiple idempotent grant kinds and Loadout demonstrates the exact shape (one enum value, one FPrimaryAssetId field, one idempotent grant API, manifest identity = the id), so reconciliation-by-diff works automatically: a relic reward newly added to a quest grants on next startup; an existing one is a no-op; a removed one leaves the player's relic alone.
Tasks.
- [x] Reward type — Source/CRADL/Quests/QuestReward.h:
- [x]
EQuestRewardType::Relic(new enum value; current set: SkillXp, Item, SpellbookUnlock, Loadout, LoadoutModifier). - [x]
FQuestReward::RelicAssetId(FPrimaryAssetId), gatedmeta=(EditCondition="RewardType==EQuestRewardType::Relic", EditConditionHides)per the existing flat-discriminator convention (mirror theLoadoutAssetIdfield). - [x] Manifest — Source/CRADL/Quests/QuestPersistentTypes.h:
- [x]
FQuestRewardManifestEntry::RelicAssetId(FPrimaryAssetId). - [x]
QuestRewardIdentityMatches: addcase EQuestRewardType::Relic: return Entry.RelicAssetId == Reward.RelicAssetId;(binary identity, no value delta — same as Loadout). - [x] Dispatch — Source/CRADL/Quests/QuestComponent.cpp:
- [x]
case EQuestRewardType::Relic:inUQuestComponent::DeliverReward— recordEntry.RelicAssetId = Reward.RelicAssetId;and callPS->GetRelicComponent()->GrantRelic(Reward.RelicAssetId), mirroring theLoadoutcase. NoSeatItemViaLadderoverflow path — relics, like Spellbook/Loadout, have no overflow surface; a missingURelicComponentis an init error (log at Warning perfeedback_log_level_warning_for_diagnostics), not a deferral. - [x] Validator — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
- [x] Add the
Relicactive-variant block: requireRelicAssetIdvalid +PrimaryAssetType == "RelicDefinition"(resolved against the known-relic-ids set, mirroring theLoadout/LoadoutModifierblocks'CollectKnownPrimaryAssetIds(...)resolution). - [x] Extend the stray-field detection so a
RelicAssetIdset on a non-Relicreward is flagged, mirroring the existing per-variant stray checks. (Grounding resolved contract Open Question #3: this reward-variant validator exists and validates per-variant required fields, so theReliccase is in-scope here, not deferred.)
Verification.
- Compile clean (Claude builds via
Build.bat). - In-editor (user): a
UQuestDefinitionwith aRelicreward whoseRelicAssetIdis empty or points at a non-RelicDefinitionfails validation; a valid one passes. SettingRelicAssetIdon aLoadoutreward flags the stray field. - Runtime (user): complete the quest →
Relic.ListUnlockedshows the relic; relaunch → reconciliation no-ops (manifest entry already present); remove the reward from the definition → the player keeps the relic (ledger never reclaims).
Footguns.
- Idempotency lives at the grant API (
GrantRelicappends only if absent) and the manifest diff — do not add a second dedup layer. - Do not unify with the voucher pathway (Phase 3); the two reach
GrantRelicindependently by design (contract Footgun; VOUCHER_SYSTEM.md "Why not the quest pipeline").
Phase 3 — Voucher Reward Pathway
Parallel-able with Phase 2. Depends on Phase 0 (URelicDefinition) + Phase 1 (GrantRelic/IsRelicUnlocked).
Goal. A relic voucher item redeems through the existing URedeemVoucherAbility path: already-owned is a waste case (player-facing toast, voucher not consumed), un-owned grants the relic after the decrement. The voucher validator gates the new kind in lockstep. No new tags or item-menu changes — a relic voucher is an ordinary Item.Voucher.
Rationale. The voucher dispatch is a closed enum-variant loop that already scales to a 4th kind by literal extension; Loadout is the exact mirror (idempotent, waste-gated, FPrimaryAssetId payload). Grounding confirmed both the waste-gate and grant-dispatch switches exist with Loadout cases, so the relic case slots into each with no abstraction refactor.
Tasks.
- [x] Voucher definition — Source/CRADL/Inventory/VoucherDefinition.h:
- [x]
EVoucherKind::Relic(new 4th value; current set: Spellbook, Loadout, LoadoutModifier). - [x]
UVoucherDefinition::RelicAssetId(FPrimaryAssetId), gatedmeta=(EditCondition="Kind==EVoucherKind::Relic", EditConditionHides). - [x] Redeem ability — Source/CRADL/Abilities/RedeemVoucherAbility.cpp:
- [x] Waste-gate switch: a
Reliccase rejecting with a toast (e.g. "You already own that relic.") whenPS->GetRelicComponent()->IsRelicUnlocked(Def->RelicAssetId)— relics are idempotent, so already-owned is a waste case, exactly like theLoadoutcase. - [x] Grant-dispatch switch: a
Reliccase callingGetRelicComponent()->GrantRelic(Def->RelicAssetId)after the unconditional decrement, mirroring theLoadoutcase'sGrantLoadoutId+ success message. - [x] Validator — Source/CRADLEditor/Validators/CradlVoucherDefinitionValidator.cpp:
- [x] Add a
Reliccase requiringRelicAssetIdvalid andPrimaryAssetType == "RelicDefinition"(the string-literalFPrimaryAssetType("RelicDefinition")pattern theLoadout/LoadoutModifiercases use).
Verification.
- Compile clean (Claude builds via
Build.bat). - In-editor (user): a
UVoucherDefinitionof kindRelicwith an invalid/wrong-typeRelicAssetIdfails validation; valid passes. TheConsumable/Vouchermutual-exclusion inCradlItemTableValidatoris unchanged — a relic voucher is a voucher, never also a consumable. - Runtime (user): author an
FItemRowwhoseVoucherreferences the relic voucher def, give it via inventory cheat, redeem it via the existingAction.Trigger.Item.Redeemcontext-menu path → relic granted, voucher consumed. Redeem a second identical voucher while owning the relic → waste toast, voucher not consumed.
Footguns.
- Do not extract a shared relic-grant orchestrator across Phases 2 and 3 — the quest and voucher pathways reach
GrantRelicindependently by design (contract Footgun). - The relic voucher rides the existing
Item.Voucher⇔FItemRow::Voucher⇔Action.Trigger.Item.Redeem⇔GameplayCue.Item.Redeempath unchanged — no new tags, no item-menu code.
Phase 4 — Selection UI & Swap Ability
Parallel-able with Phase 5. Depends on Phase 0 + Phase 1 (SetActiveRelic/IsRelicUnlocked/OnUnlocksChanged). Phase 1's Relic.Grant cheat provides the unlocked relics this surface displays.
Goal. At the existing loadout terminal, the player sees their unlocked relics (plus a leading "no relic" sentinel) in a new URelicSlotPanel on ULoadoutSwapWidget, and selecting a row eager-applies — dispatching Action.Trigger.RelicSwap to a LocalPredicted URelicSwapAbility that calls SetActiveRelic on authority. No Apply button. The PIP render is Phase 5; this phase verifies via OnRelicChanged firing and the active id changing.
Rationale. The pilot panel (ULoadoutModifierSlotPanel) is the proven template for a single-active, sentinel-led, eager-apply selection of an unlocked cosmetic in this same terminal modal: button-group churn suppression (UCommonButtonGroupBase + bSuppressSelectionDispatch), sentinel-clear, presentation-struct hand-off, and a LocalPredicted swap ability gated by Action.Modal.LoadoutSwap. Reusing its shape keeps the relic surface consistent and avoids re-deriving the CommonUI selection mechanics.
Tasks.
- [x] Presentation struct — Source/CRADL/UI/RelicPresentation.h (new):
- [x]
FRelicPresentationDatabuilt C++-side with pre-resolvedDisplayName,Icon, requirement arrays, andbIsActive— the WBP never resolves anFPrimaryAssetId(ARCHITECTURE.md #12;feedback_presentation_struct_resolve_tags). - [x] List item — Source/CRADL/UI/RelicEntryListItem.h/
.cpp(new): - [x]
URelicEntryListItemfedFRelicPresentationData(mirror the modifier panel's row item). Includes the innerURelicSelectButton(Source/CRADL/UI/RelicSelectButton.h/.cpp, new) mirroringULoadoutModifierSelectButtonso the row's WBP authoring surface matches the modifier panel's exactly. - [x] Panel — Source/CRADL/UI/RelicSlotPanel.h/
.cpp(new): - [x]
URelicSlotPanel : UUserWidgetwith aUCommonButtonGroupBase+ a suppress-dispatch bool (mirrorbSuppressSelectionDispatch), a leading sentinel "no relic" row (invalid id → clears), rows ofURelicEntryListItem. Single-active (no slot index / SlotTag filter), keyed onActiveRelicId; bindsOnUnlocksChanged+OnRelicChanged.ListItemClassisEditAnywhereperfeedback_umg_widget_uproperty_edit_specifier(the older modifier panel'sEditDefaultsOnlyis the pattern this Footgun supersedes). - [x] Row selection calls
ASC->HandleGameplayEvent(Action.Trigger.RelicSwap, payload)with aURelicSwapRequestcarrying the terminal + relic id (mirrorDispatchModifierSwap+ULoadoutModifierSwapRequest). Dispatch deliberately does not gate onRelicId.IsValid()— an invalid id is the legitimate clear dispatch. - [x] An applied-toast delegate (
FOnRelicSwapApplied, two-param: presentation + bCleared) the host widget binds. - [x] Swap ability — Source/CRADL/Abilities/RelicSwapAbility.h/
.cpp(new): - [x]
URelicSwapRequest(UObjectpayload: terminalTWeakObjectPtr<AActor>+FPrimaryAssetId), mirroringULoadoutModifierSwapRequest. - [x]
URelicSwapAbility(NetExecutionPolicy = LocalPredicted,ActivationRequiredTags = Action.Modal.LoadoutSwap,ActivationBlockedTags = Status.InCombat), mirroringULoadoutModifierSwapAbility: authority re-validates terminalCanInteractthen callsSetActiveRelic(RelicId, bCheckRequirements=true)— the single API owns the valid/invalid branch internally (unlocked + requirements for a valid id; clear for invalid). - [x] Granted in C++ on
ACradlPlayerStatewith the!DefaultAbilities.Contains(...)dedupe (mirrors the Voucher/AutoAttackHandleGameplayEvent-triggered grants — more robust than the BP-listed loadout/modifier swaps; works without a BP edit). - [x] Host widget — Source/CRADL/UI/LoadoutSwapWidget.h/
.cpp(extend): - [x]
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<URelicSlotPanel> RelicPanel;(mirror the existingModifierSlotPanelmember). - [x]
RefreshRelicPanel()buildingFRelicPresentationDatarows fromURelicComponent. Not loadout-gated (relics are independent of the active loadout): shown whenever ≥1 relic is unlocked, collapsed otherwise (Open Question #2 — proposed rule). Called onBindPlayer+NativeOnActivated, not onHandleLoadoutChanged(relics don't re-shape on loadout swap). The host also binds the relic component'sOnUnlocksChanged→RefreshRelicPanelso a 0→1 unlock while the terminal is open flips the panel collapsed→visible live (no reopen). (The sibling modifier panel needs no equivalent: its visibility is gated on the active loadout's slot shape — already live viaOnLoadoutChanged— and it's shown-with-sentinel even at zero unlocked modifiers, so it's always bound and catches its first unlock already.) - [x] Bind the panel's applied-toast delegate in
NativeConstruct/ unbind inNativeDestruct(feedback_bind_child_delegate_in_nativeconstruct— modal pages are reused; binding inNativeOnInitializedgoes dead after the first reopen).
Verification.
- Compile clean (Claude builds via
Build.bat). - Runtime (user):
Relic.Grant X+Relic.Grant Y, open the loadout terminal → the relic panel shows the sentinel + X + Y. Select X →OnRelicChanged(X)logged, active id = X, applied toast shows. Select the sentinel → active id cleared. With zero relics unlocked, the panel is collapsed. - Dispatch gating: firing
Action.Trigger.RelicSwapoutside the terminal (noAction.Modal.LoadoutSwap) fails to activate the ability, identical to the loadout/pilot verbs.
Footguns.
- Replication (restated): the swap is
LocalPredictedbut theSetActiveRelicmutation isHasAuthority-gated; the result flows back viaActiveRelicId'sCOND_OwnerOnlyOnRep. Prediction is vestigial (the local payoff is the Phase 5 relic respawn, which theOnRepdrives anyway) but keeps the dispatch path identical to the sibling verbs. - Avoid
Slotas a local/parameter name anywhere inURelicSlotPanel— C4458 shadowsUWidget::Slot(feedback_slot_shadows_uwidget); useRelicSlot/SlotData. - Designer-tuned panel UPROPERTYs (
ListItemClass, list container) areEditAnywhere, notEditDefaultsOnly(feedback_umg_widget_uproperty_edit_specifier). - If a relic row is ever a CommonUI toggle rather than a list button, bind
OnIsSelectedChanged, notOnClicked(reference_commonui_toggle_bind_selectedchanged).
Phase 5 — PIP Display & Cockpit Anchor
Parallel-able with Phase 4. Depends on Phase 0 (ARelicVisualsActor) + Phase 1 (OnRelicChanged key). Verified via Phase 1's Relic.SetActive cheat — does not require Phase 4's UI.
Pre-work. This extends the live cockpit/pilot pipeline in ULoadoutPreviewSubsystem (see PILOT_IMPLEMENTATION.md for that build). Grounding confirmed the subsystem already has the two-stage async-load + supersede-guard member set per rig (CockpitRig/CockpitLoadHandle/ActiveLoadoutIdLocal/PendingCockpitClass/LoadoutChangedHandle, and the pilot equivalents), the spawn/allowlist/capture primitives (SpawnRigIntoStaging, AddActorToAllowlist, CaptureNow, SetRigTicksEnabled, GPreviewStagingSpawnPoint), and the readiness/block API (EnterNotReady, MarkCockpitSettled, MarkPilotSettled, SetVisualBlocked). The relic pipeline mirrors the cockpit member set and reuses the spawn primitives without reshaping the core — no refactor of the existing rigs is required.
Goal. Selecting a relic makes its VisualsActor appear in the pilot PIP, attached to a per-cockpit anchor; clearing it despawns silently; swapping loadouts re-anchors the relic to the new cockpit. The swap is silent — no readiness/block gating, no pilot-view eject. This closes the loop: a relic earned (2/3), selected (4 or cheat), and now shown.
Rationale. The subsystem already spawns two local rigs from replicated driver keys with two-stage async loads and a supersede guard; a third pipeline fits the established shape. The two deliberate divergences — a per-cockpit attach anchor via a preview-specific interface, and no readiness/block gating on swap — are the contract's explicit requirements and are cheap precisely because readiness gating is opt-in per handler, not global.
Tasks.
- [x] Display-location interface — Source/CRADL/Visuals/RelicDisplayProvider.h/
.cpp(new): - [x]
IRelicDisplayProvider/URelicDisplayProvider— a preview-onlyUInterfacemodeled exactly onIPreviewCameraProvider.GetRelicAnchor() const(BlueprintNativeEvent, defaultnullptr) returns theUSceneComponentthe relic attaches to. - [x]
Cradl::Visuals::ResolveRelicAnchor(AActor* Cockpit)(new free function): interface override if implemented and non-null → else the firstUSceneComponentcarrying theRelicAnchorFNamecomponent tag → elsenullptr. Anullptranchor means no relic shown for that cockpit. - [x] Relic pipeline — Source/CRADL/Visuals/LoadoutPreviewSubsystem.h/
.cpp(extend): - [x] Members mirroring the cockpit set:
TWeakObjectPtr<ARelicVisualsActor> RelicRig,TSharedPtr<FStreamableHandle> RelicLoadHandle,FPrimaryAssetId ActiveRelicIdLocal(supersede + same-key short-circuit),TSoftClassPtr<ARelicVisualsActor> PendingRelicClass,FDelegateHandle RelicChangedHandle. - [x] In
BindDriverKeys: bindURelicComponent::OnRelicChanged→HandleRelicChanged(FPrimaryAssetId). - [x] Pipeline:
HandleRelicChanged→RequestRelic(resolveURelicDefinition, async if not resident) →OnRelicClassResolved(resolveURelicDefinition::VisualsActorsoft class) →SpawnRelicRig(viaSpawnRigIntoStaging:RF_Transient, visible, not replicated, atGPreviewStagingSpawnPoint; thenAttachToComponent+AddActorToAllowlist) →ClearRelicRigon swap/clear. Anim tick pauses with PIP visibility viaSetRigTicksEnabled. (Attach uses the project rig convention —SnapToTargetlocation/rotation +KeepWorldscale, as inULoadoutVisualsComponent/UEnemyVisualsComponent— not the looseKeepRelativeTransformshorthand, which would offset the rig by the staging origin.) - [x] Cockpit re-anchor: funneled through
MarkCockpitSettled(the single cockpit-terminal-state hook — covers spawn success, spawn fail, and no-cockpit) →RefreshRelicAnchor, which re-attaches an existing relic to the fresh anchor, spawns a deferred relic, or despawns the relic when the new cockpit resolves no anchor.HandleRelicChangeddefers if no cockpit rig is present yet. Mirrors the subsystem's existing seed-vs-broadcast ordering. - [x] Silent swap:
HandleRelicChanged/RefreshRelicAnchor/ClearRelicRignever callEnterNotReady,MarkCockpitSettled/MarkPilotSettled, orSetVisualBlocked. Old relic actor destroyed (its dead weak ref compacts out of the allowlist on nextCaptureNow); new one spawns/attaches; PIP keeps rendering with no placeholder, no eject.
Verification.
- Compile clean (Claude builds via
Build.bat). - Runtime (user, with a cockpit BP that authors a
RelicAnchor-taggedUSceneComponentor implementsIRelicDisplayProvider):Relic.SetActive X→ X's scene actor appears in the pilot PIP at the anchor.Relic.SetActive Y→ X despawns, Y appears, PIP never blanks or ejects.Relic.Clear→ relic despawns silently. - Re-anchor: with a relic active, swap the loadout (cockpit rebuilds) → the relic re-anchors to the new cockpit's anchor.
- No-anchor cockpit: a cockpit BP authoring no anchor → no relic shown, no error.
Footguns.
- Don't reach for
IVisualsAnchorProviderfor the relic socket — it is the in-world rig-attach surface (implemented onACradlCharacter) with a driver-mesh fallback; reusing it would conflate two roles. The preview-specificIRelicDisplayProvidermirrorsIPreviewCameraProvider's precedent. - 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 isRF_Transient+bReplicates = falseand left visible — the capture's show-only list renders allowlisted actors;SetActorHiddenInGamewould hide it from the capture too. - Do not broadcast or networked-spawn the relic actor — it is per-local-player, rebuilt from the
COND_OwnerOnlykey.
Open Questions
Carried from RELIC_SYSTEM.md. None blocks a phase — v1 builds the stated default in each case; resolution only changes whether a later, additive follow-up is wanted.
ARelicVisualsActorreaction surface (Phase 0). v1 is a thin display actor with no reaction BIEs. A minimalNotifyActivated()(intro flourish on spawn/re-anchor) or the fullerAModifierVisualsActorreaction set is an additive later change — the thin actor ships first.- Relic panel visibility when zero unlocked (Phase 4). Built collapsed-until-≥1-unlocked (the proposed rule). Switching to always-shown-with-only-the-sentinel is a one-line visibility change in
RefreshRelicPanel(). A UX call. - Quest reward-variant validator coverage (Phase 2) — RESOLVED by grounding.
CradlQuestDefinitionValidatordoes validate per-variant required fields (incl. PrimaryAssetType resolution and stray-field detection), so theRelicreward-validator case is in-scope in Phase 2, not deferred. - Relic selection requirements in v1 (Phases 0/1/4).
URelicDefinitioncarries optionalSkillRequirements/PrerequisiteQuestsand the swap path enforces them; whether v1 content uses any gate is a content decision, not a code one. The fields and enforcement ship regardless.