CRADL Pilot Visual Representation Implementation
Companion to PILOT_SYSTEM.md (the contract), STAT_PIPELINE.md (the pilot data side, which is done and out of scope here), and ARCHITECTURE.md. This doc tracks the build order for v1 of the pilot's visual picture-in-picture (PIP): phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what the pilot PIP is; this doc says what we build first, what depends on what, and how we know each step works.
This is a greenfield visual layer on top of finished data plumbing. The pilot's slot assignment, stat GEs, and persistence already exist (STAT_PIPELINE.md "Pilot (loadout modifier)"); nothing in this build touches the wire. It reuses the existing visuals foundation — the AVisualsRigActor host, the driver-key async-spawn (LoadoutVisualsComponent.cpp), the UCradlVisualStateComponent event surface, the BindWidgetOptional HUD child — and adds a capture pipeline, not a foundation.
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
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md,UFUNCTIONdeclarations are unconditional; only the.cppbody is wrapped in the guard (never combineUFUNCTION+#ifin a header). - Per CLAUDE.md "validators in lockstep": any phase that touches
ULoadoutDefinition(addingPreviewVisualsActor) updatesUCradlLoadoutDefinitionValidatorin the same change. Tag checks in the validator resolve viaRequestGameplayTagstrings, neverCradlTags::X(perfeedback_native_tag_no_cross_module_export.md). - Per CLAUDE.md "C++ for logic, BP is a data container": C++ owns the capture/spawn/readiness logic; the WBP authors only cosmetic visuals (placeholder spinner, anim reactions). No Event Graph logic.
- Per CLAUDE.md "Building": after a phase's C++ edits land, Claude compiles with the documented
Build.batcall (UE: Build Editor (Development)) and confirms it links exit-0 before marking the phase done. Runtime/PIE verification remains the user's job. - Local-only mandate: this entire system is local +
RF_Transientand adds zero replicated state. Every spawned actor isbReplicates=false; the subsystem builds only where a localULocalPlayerexists. See PILOT_SYSTEM.md "Replication Audit".
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | PocketWorlds vendoring & compile gate | [x] |
All later phases |
| 1 | Data scaffolding & class shells | [x] |
2–7 |
| 2 | Subsystem lifecycle & pocket-level staging | [x] |
3, 4 |
| 3 | Capture pipeline & HUD mount (test-fixture frame) | [x] |
4 |
| 4 | Scene composition: driver-key rig spawn + retire pawn stub | [x] |
5, 6, 7 |
| 5 | Capture cadence: live-while-visible + pause | [x] |
6 |
| 6 | Initialization & swap gating | [x] |
— (parallel-able with 7) |
| 7 | Event-reaction surface | [x] |
— (parallel-able with 6) |
| 8 | External block / feed-offline signal | [x] |
— |
Phase 0 — PocketWorlds Vendoring & Compile Gate
Goal. The vendored Plugins/PocketWorlds/ plugin compiles against UE 5.4 inside CRADL, is enabled in CRADL.uproject, declared in the CRADL module Build.cs, and its content (M_PocketCaptureMasked + the ~3 material assets) imports cleanly. No CRADL consumer code yet — this phase exists solely to retire the system's single biggest external risk before anything is built on it.
Rationale. PILOT_SYSTEM.md "Capture Mechanism" makes the whole design ride on PocketWorlds, and Open Question #1 calls vendoring "load-bearing on the whole system" and "a Phase-0 gate before any consumer code is built." PocketWorlds is a Lyra sample plugin (not engine-native) with an engine-deps-only surface (Core/CoreUObject/Engine, EnabledByDefault:false) — cheap to vendor, but unproven inside this project until it links. Isolating it lets a port failure surface with zero sunk consumer code.
Tasks.
- [x] Vendor the plugin — copy
LyraStarterGame54/Plugins/PocketWorlds/verbatim toPlugins/PocketWorlds/(perproject_pocketworlds_vendoring.md). - [x] Register the plugin — add the
PocketWorldsentry to thePluginsarray inCRADL.uproject(Enabled: true;.upluginisEnabledByDefault:falseso explicit enable required). - [x] Declare the module dependency — added
PocketWorldstoPublicDependencyModuleNamesin CRADL.Build.cs. - [x] Confirm content imports —
M_PocketCaptureMaskedand the plugin's material assets cook/load without error inside CRADL (user-confirmed in-editor). - [x] Spot-check the vendored API surface the contract names exists post-copy:
UPocketCapture,UPocketCaptureSubsystem,UPocketLevel,UPocketLevelInstance,UPocketLevelSubsystem(all compiled + linked in the clean build).
Build note (2026-05-30): Clean rebuild links exit-0;
UnrealEditor-PocketWorlds.dll+UnrealEditor-CRADL.dll(now PocketWorlds-dependent) +UnrealEditor-CRADLEditor.dllall link clean. Wart: PocketLevelInstance.cpp emits C4996 —ULevelStreaming::ECurrentState/GetCurrentStatedeprecated in 5.4 (useELevelStreamingState/GetLevelStreamingState). Compiles today; will hard-fail on the next engine bump. One-line fix in vendored source if/when desired.
Verification.
- Compile clean (user-side) with the plugin enabled.
- Editor launches with
PocketWorldsin the enabled-plugins list; no missing-redirector / failed-content warnings forM_PocketCaptureMasked. - This phase resolves Open Question #1. If the port does not compile against 5.4, stop — the whole system is blocked and the contract needs revisiting before Phase 1.
Exits. Every later phase can now reference vendored UPocket* types. Phase 1 can include them as members.
Phase 1 — Data Scaffolding & Class Shells
Goal. A compile-clean codebase with every (new) C++ symbol present as a behavior-free shell, plus the one new DataAsset field and its validator update. After this phase the editor can author the new field and the project links — but nothing renders.
Rationale. "Phase 0 is always scaffolding" (skill heuristic). Landing all shells + the data field first means every later behavior phase edits an existing file rather than introducing a class, and the validator stays in lockstep from the first commit that touches ULoadoutDefinition.
Tasks.
- [x] DataAsset field — LoadoutDefinition.h:
- [x] Add
PreviewVisualsActor(new)TSoftClassPtr<ALoadoutPreviewVisualsActor>, mirroring the existingVisualsActorTSoftClassPtr<ALoadoutVisualsActor>declaration. Null = no cockpit (pilot renders against bare backdrop). Forward-declareALoadoutPreviewVisualsActor. Compiled clean (2026-05-30). - [x] Validator update — CradlLoadoutDefinitionValidator.{h,cpp} (same change, per CLAUDE.md lockstep):
- [x] Validate
PreviewVisualsActor, when set, is resolvable and derives fromALoadoutPreviewVisualsActor. Null is valid (cockpit optional). Mirrors theVisualsActordangling check (_C-strip) + aLoadSynchronous→IsChildOftype guard (editor-only load is fine; the no-sync-load rule governs gameplay). Compiled clean (2026-05-30). - [x] New validator — CradlLoadoutModifierDefinitionValidator.{h,cpp} (resolves Open Question #2 — committed to this work, not deferred):
- [x]
UCradlLoadoutModifierDefinitionValidatormirrorsUCradlLoadoutDefinitionValidator:DisplayNamenon-empty,Iconset + resolves,SlotTagvalid + underModifier.*(root resolved viaRequestGameplayTag("Modifier")string — neverCradlTags::X, perfeedback_native_tag_no_cross_module_export.md),VisualsActorresolves when set (_C-strip). NullStatsEffect/VisualsActorvalid. Auto-registers viaUEditorValidatorBasediscovery (no manual registration). Compiled clean (2026-05-30). - [x] Add
ULoadoutModifierDefinitionto the CLAUDE.md "currently validated" list. - [x] Cockpit rig shell —
Source/CRADL/Loadout/LoadoutPreviewVisualsActor.{h,cpp}(new): - [x]
class CRADL_API ALoadoutPreviewVisualsActor : public AVisualsRigActor. ReaffirmsbReplicates = falsein the constructor (the base already sets it; explicit for the local-only mandate). No behavior beyond the base rig host. Compiled clean (2026-05-30). - [x] Subsystem shell —
Source/CRADL/Visuals/LoadoutPreviewSubsystem.{h,cpp}(new): - [x]
class CRADL_API ULoadoutPreviewSubsystem : public UWorldSubsystem. Member set declared (TObjectPtr<UPocketLevelInstance>,TObjectPtr<UPocketCapture>,TObjectPtr<UTextureRenderTarget2D>, weak rig refs, twoTSharedPtr<FStreamableHandle>, threeFDelegateHandle,bPreviewReady) — UObject holdersUPROPERTY(Transient).Initialize/Deinitializeoverrides are empty shells. Compiled clean (2026-05-30). - [x] Readiness delegate — declared
FOnPreviewReadyChanged(new)DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(bool bReady)(BlueprintAssignable) +bool IsPreviewReady() constgetter on the subsystem (used from Phase 6). - [x] HUD widget shell —
Source/CRADL/UI/CradlLoadoutPreviewWidget.{h,cpp}(new): - [x]
class CRADL_API UCradlLoadoutPreviewWidget : public UCradlUserWidget. AUPROPERTY(meta=(BindWidget)) TObjectPtr<UImage> PreviewImage;. DeclaredUFUNCTION(BlueprintImplementableEvent) void OnPreviewReadyStateChanged(bool bReady);andUFUNCTION(BlueprintPure) bool IsPreviewReady() const;(cpp returnsfalseuntil Phase 6 wiring). No binding logic yet. Compiled clean (2026-05-30). - [x] HUD mount point — CradlHUDLayout.h:
- [x] Added
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<UCradlLoadoutPreviewWidget> LoadoutPreview;+ forward declaration, matching thePlayerHUD/MessageLogoptional-child shape. Compiled clean (2026-05-30). - [x] Pilot-rig BIE declarations — ModifierVisualsActor.h:
- [x] Declared (unconditionally) the reaction BIEs as
UFUNCTION(BlueprintImplementableEvent):NotifyXPGained(FGameplayTag, int64, int64),NotifyLevelUp(FGameplayTag, int32),NotifyHealthChanged(float NewHealth, float OldHealth),NotifyCombatStateChanged(bool),NotifyActivated()(declared asNotifyGameplayStarted()in this phase; renamed in Phase 7 to match its per-spawn behavior) — signatures aligned to the VSC forwards in Phase 7's table. MirrorsALoadoutVisualsActor::NotifyPlayerDeathStateChanged. Cosmetic-only. Compiled clean (2026-05-30). - [x] Settings class — CradlPilotPreviewSettings.h (new — Open Decision RESOLVED in favor of the settings class):
UCradlPilotPreviewSettings : public UDeveloperSettings(config=Game, defaultconfig, category CRADL, DisplayName "Pilot Preview"), mirroringUCradlCombatSettings. HoldsRenderTargetSize(384, 64–2048),bCaptureEveryFrame(true),MaxCaptureFPS(0 = uncapped, EditCondition on bCaptureEveryFrame),MinPlaceholderHoldSeconds(0.35). Consumed in Phases 3/5/6. Compiled clean (2026-05-30).
Open Decision — RESOLVED (user, 2026-05-30).
- Open Question #4 — settings home. Resolved in favor of a dedicated
UCradlPilotPreviewSettings(UDeveloperSettings) rather than subsystem constants, so RT size / cadence cap / min placeholder hold are editor-tunable from the start. Added as a Phase 1 task above.
Open Question #2 (modifier-definition validator) is resolved — added in this work (see the New validator task above).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0. All eight new/edited TUs compiled (LoadoutPreviewSubsystem.cpp,LoadoutPreviewVisualsActor.cpp,ModifierVisualsActor.cpp,CradlLoadoutPreviewWidget.cpp,LoadoutDefinition.cpp,CradlHUDLayout.cpp, both validators); UHT ran with-WarningsAsErrorsso the headers + BIE/delegate reflection are clean.UnrealEditor-CRADL.dll+UnrealEditor-CRADLEditor.dllboth link. No warnings.
Verification.
- Compile clean — done (Claude build above; links exit-0).
- (user, runtime) In the editor, open a
ULoadoutDefinitionasset: the newPreviewVisualsActorfield appears. Set it to a non-ALoadoutPreviewVisualsActorclass → asset validation fails with the new validator message; set it to a valid subclass or leave null → validation passes. - (user, runtime) A HUD WBP that adds a
UCradlLoadoutPreviewWidgetchild namedLoadoutPreviewbinds without error; a HUD WBP that omits it still compiles (BindWidgetOptional).
Exits. Phases 2–7 each extend an existing shell. The data field + validator are done; no later phase reopens LoadoutDefinition.
Footguns.
- Don't combine
UFUNCTIONwith#if !UE_BUILD_SHIPPINGin the BIE declarations (CLAUDE.md) — declare unconditionally. - Don't name any widget local
Slot— it shadowsUWidget::Slot(perfeedback_slot_shadows_uwidget.md); usePreviewImage/Src.
Phase 2 — Subsystem Lifecycle & Pocket-Level Staging
Goal. ULoadoutPreviewSubsystem builds exactly once per local player, gated on the gameplay-start hook, streams a UPocketLevelInstance off-origin, and tears it down cleanly on Deinitialize. No capture or rigs yet — just the staging world coming and going on the right clients.
Rationale. "One spine before its verbs": the pocket world is the container every later phase spawns into. The local-player gate + readiness trigger + idempotent build are the structural enforcement of the local-only mandate (PILOT_SYSTEM.md "Ownership & Lifecycle"), so they land before anything renders.
Tasks.
- [x] Local-player gate —
ShouldCreateSubsystemrestricts toIsGameWorld()(no editor preview / inactive worlds);OnWorldBeginPlayearly-outs onNM_DedicatedServer, and the build path resolves the local PC/ULocalPlayer(no local player → no-op). The net-mode gate lives at world-begin, not creation, because net mode isn't reliable at subsystem-creation time. See PILOT_SYSTEM.md "Off-Screen Staging" footgun on per-local-player instancing. Compiled clean (2026-05-30). - [x] Gameplay-start trigger —
OnWorldBeginPlay→EstablishReadyTrigger: resolves the local PC, bindsAPlayerController::OnPossessedPawnChanged, and registersUCradlVisualStateComponent::CallWhenReady(CradlVisualStateComponent.h) against the possessed pawn; the ready callback runsBuildPreview. A short re-arm timer covers the network-client case where the local PC hasn't replicated in by world-begin (aUWorldSubsystemhas noOnRep_PlayerState-style hook). Re-registers for fresh pawns viaOnPossessedPawnChanged. Per PILOT_SYSTEM.md "Gameplay-Start Hook". Compiled clean (2026-05-30). - [x] Pocket instance build —
BuildPreview/FinishBuild: resolves the localULocalPlayer, async-loads the stagingUPocketLeveldescriptor (newUCradlPilotPreviewSettings::PreviewPocketLevelsoft ref — no synchronous loads per CLAUDE.md; resident-fast-path skips the hop), then callsUPocketLevelSubsystem::GetOrCreatePocketLevelFor(vendored) at a far off-origin spawn point ((0,0,10 km)— large-but-not-extreme) andStreamIns it. Idempotent: guarded byPocketInstance+ the in-flight load handle so re-entry fromCallWhenReady/possession churn never double-builds. Compiled clean (2026-05-30). - [x] Teardown —
Deinitializeclears the retry timer, unbindsOnPossessedPawnChanged, cancels the in-flight load, andStreamOuts the pocket instance. (The vendoredUPocketLevelInstancestays owned byUPocketLevelSubsystem— no remove API — butStreamOutunloads the heavy streamed level; both world subsystems are recreated on PIE re-entry, so nothing leaks across sessions.) No replicated state. Compiled clean (2026-05-30). - [x] Cheat command — CradlPlayerController exec,
#if !UE_BUILD_SHIPPINGbody only:Debug_DumpPilotPreview(exec UFUNCTIONs can't carry the dottedCradl.Pilot.DumpPreviewStateform; named to match the file'sDebug_*convention) → logs build state, the pocket-instance handle, the local-player gate, the possessed pawn, the bound VSC, and load-in-flight via the subsystem'sGetDebugStateString(). Diagnostic at Warning level (perfeedback_log_level_warning_for_diagnostics.md). Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors.LoadoutPreviewSubsystem.cpp+CradlPlayerController.cppcompiled,UnrealEditor-CRADL.dlllinked. New settings fieldUCradlPilotPreviewSettings::PreviewPocketLevel(TSoftObjectPtr<UPocketLevel>, category "Staging") is the staging-level home. Runtime verification is gated on the deferred content asset/Game/Visuals/Preview/PL_LoadoutPreviewexisting and being assigned in Project Settings → CRADL → Pilot Preview; until thenBuildPreviewno-ops with a Warning andDebug_DumpPilotPreviewreportsbuilt=no.
Verification.
- PIE as a listen-server host + one client: both build their own pocket instance (log via the cheat command); a dedicated-server world (or a remote proxy) builds none.
- The
UPocketLevelcontent asset (/Game/Visuals/Preview/PL_LoadoutPreview, authored as a deferred content task) streams in off-origin; the world outliner shows the streamed staging sublevel only on local-player worlds. - Re-enter PIE: the instance rebuilds with no leak (teardown ran).
Pre-work.
- Content authoring (deferred, can land in parallel): the
UPocketLeveldata asset + its templateULevel(staging room with its own lighting/post) under/Game/Visuals/Preview/. The phase can verify against an empty staging level; lighting authoring is a content pass.
Footguns.
- Late-bind on startup — the local
ACradlPlayerStateand its loadout/modifier components arrive after world begin on clients; rely onCallWhenReady(fires immediately if already ready) + theOnPlayerStateReplicatedre-probe, and seed current state on bind rather than waiting for the next change broadcast (per PILOT_SYSTEM.md "Ownership & Lifecycle"). - Off-origin precision — stage at the PocketWorlds default offset, not the world-bounds extreme, to avoid float-precision wobble in skeletal anim.
- Idempotent build —
CallWhenReady+ possession churn can re-enter; building must no-op if an instance already exists.
Phase 3 — Capture Pipeline & HUD Mount (Test-Fixture Frame)
Goal. The subsystem owns a UPocketCapture + UTextureRenderTarget2D capturing the pocket world via PRM_UseShowOnlyList; the HUD widget binds that RT to its UImage brush. Verified against a cheat-spawned debug primitive in the pocket level (the test fixture) — proving the capture → RT → brush chain before any rig-spawn logic exists.
Rationale. "Test fixture before content" (skill heuristic, mirroring COMBAT Phase 5's target dummy). The capture chain and the rig-spawn chain are independent failure surfaces; verifying capture with a trivial allowlisted primitive isolates "does the picture reach the HUD" from "do the right rigs spawn" (Phase 4).
Tasks.
- [x] Capture + RT — subsystem creates the capture + a
UTextureRenderTarget2D. VendoredUPocketCaptureisAbstract, so a minimal concrete subclassULoadoutPreviewCapture(new — LoadoutPreviewCapture.{h,cpp}) was added; it exposesCaptureAllowlist(actors)over the protectedCaptureScenesoShowOnlyActorsis an explicit list, not the capture target's attachment hierarchy (the vanillaCaptureDiffusepath).PRM_UseShowOnlyListis set by the vendoredInitialize; the subsystem sizes the RT fromUCradlPilotPreviewSettings::RenderTargetSize(384) and pulls the diffuse RT up-front. The capture needs aUCameraComponent-bearing target → the subsystem spawns a framingACameraActor(SpawnCaptureCamera) at the staging origin. Compiled clean (2026-05-30). - [x] Allowlist plumbing —
AddActorToAllowlistadds to an authoritativeTArray<TWeakObjectPtr<AActor>>;CaptureNowbuilds the live list (compacting dead weak refs — rig churn / destroyed fixture) and callsCaptureAllowlist. Only listed actors ever render. Used by the fixture now, the rigs in Phase 4. Compiled clean (2026-05-30). - [x] Per-frame capture driver (capture mechanism — user-confirmed in this phase) — driven from an
FTSTicker(TickCapture→CaptureNow), the PocketWorlds-idiomatic manual per-frame capture (its ownUPocketCaptureSubsystemuses the same ticker), which is also what keepsStreamThisFramemip-pinning alive — so it can't be the component's ownbCaptureEveryFrame. Gated onbCaptureActive(set when the pipeline builds) +UCradlPilotPreviewSettings::bCaptureEveryFrame. Phase 5 layers the visibility-pause + FPS cap ontobCaptureActive— nothing here is thrown away. Compiled clean (2026-05-30). - [x] HUD widget binding — CradlLoadoutPreviewWidget.cpp:
NativeConstruct→GetPreviewSubsystem()->GetPreviewRenderTarget()→PreviewImage->SetBrushResourceObject(RT).GetPreviewRenderTarget()lazily builds the capture pipeline (EnsureCapturePipeline, idempotent, local-player-gated), so the RT exists whichever wins the race — the widget construct or a future build trigger — and a HUD with no PIP viewer never builds capture at all. Pure view, no capture logic in UMG (CLAUDE.md). Compiled clean (2026-05-30). - [x] Cheat command —
Debug_SpawnPilotPreviewPrimitive(exec UFUNCTIONs can't carry the dottedCradl.Pilot.SpawnDebugPrimitiveform — named to match the file'sDebug_*convention, as with Phase 2'sDebug_DumpPilotPreview).#if !UE_BUILD_SHIPPINGbody on CradlPlayerController →ULoadoutPreviewSubsystem::SpawnDebugPrimitive(also shipping-stripped): ensures the pipeline, spawns an engine sphereAStaticMeshActorat the staging origin (dev-only sync load — the no-sync-load rule governs gameplay, not cheats), adds it to the allowlist. Compiled clean (2026-05-30).
Lighting-isolation correction (logged, 2026-05-30). The contract's "PocketWorlds buys lighting isolation" was overstated for CRADL's during-gameplay PIP. The vendored instance streams the staging level into the same UWorld off-origin (not a separate world); isolation is a stack — off-origin distance (local lights / height fog), the capture's show-flag disabling (skylight / volumetric fog / PP), the staging level's own key light, and PRM_UseShowOnlyList (geometry). The one residual gap is a gameplay directional light, which bleeds onto the staged subject and is closed by a lighting-channel convention (content / Phase-4, not code). Corrected in PILOT_SYSTEM.md "Off-Screen Staging". No Phase-3 code impact.
Sky-pass bleed fix (logged, 2026-05-30; user-reported at content-authoring time). A second residual gap surfaced once the staging level was authored against a SkyAtmosphere gameplay map: the main map's skybox renders into the PIP at the far plane. Same root cause class as the directional light — PRM_UseShowOnlyList filters mesh primitives only, so it cannot exclude the Sky Atmosphere / height fog / volumetric cloud passes (not primitives), and the vendored CaptureScene disables SkyLighting (the skylight's ambient contribution) but leaves the visible sky passes on. Closed in code (not content, unlike the directional-light channel convention): LoadoutPreviewCapture.cpp CaptureAllowlist now sets ShowFlags.SetAtmosphere/SetFog/SetCloud(false) on the capture component each frame before delegating to CaptureScene. Background pixels then stay transparent (vendored bConsiderUnrenderedOpaquePixelAsFullyTranslucent), so the pilot/cockpit composite over the HUD; a visible backdrop, if wanted, must be authored into an allowlisted rig (the staging room is a light rig only, never in ShowOnlyActors). Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors. New TULoadoutPreviewCapture.cpp+ editedLoadoutPreviewSubsystem.cpp,CradlLoadoutPreviewWidget.cpp,CradlPlayerController.cppall compiled;UnrealEditor-CRADL.dlllinked. No warnings. Runtime verification is gated on the deferred content asset/Game/Visuals/Preview/PL_LoadoutPreview(Project Settings → CRADL → Pilot Preview) and a HUD WBP that binds aUCradlLoadoutPreviewWidgetnamedLoadoutPreviewwith aUImagechild namedPreviewImage. Until the staging level is assigned the pocket instance no-ops (Phase 2), but the capture pipeline + brush still build lazily, soDebug_SpawnPilotPreviewPrimitivewill render the sphere against whatever lighting reaches the staging origin.
Verification.
- Run
Debug_SpawnPilotPreviewPrimitive: the debug mesh appears live in the HUD PIPUImage, lit by the staging level's lighting (a gameplay directional light may also contribute until the lighting-channel convention lands — see the correction note above). - Nothing from the gameplay world's geometry leaks into the PIP (
PRM_UseShowOnlyListisolation holds). - A HUD without the optional widget still runs (the capture pipeline is never built — lazy on the widget's RT request).
Footguns.
PRM_UseShowOnlyListis isolation, not camera placement — the capture renders exactly the allowlist regardless of component position; keep the allowlist authoritative (per PILOT_SYSTEM.md "Capture Mechanism").- Don't move RT/capture ownership into the widget — they live on the subsystem; the widget only displays (CLAUDE.md).
Phase 4 — Scene Composition: Driver-Key Rig Spawn + Retire Pawn Stub
Goal. The subsystem async-spawns the cockpit rig (ALoadoutPreviewVisualsActor, keyed off ULoadoutComponent::ActiveLoadoutId) and the pilot rig (AModifierVisualsActor, keyed off the Modifier.Pilot assignment) into the pocket world, adds them to the allowlist, and rebuilds on driver-key change with an in-flight-cancel guard. The existing pawn-side origin-spawn is retired in the same change.
Rationale. This is the spine's payload — the real subjects replacing Phase 3's fixture. Retiring the pawn stub here (not later) honors feedback_no_clean_up_later.md: the contract makes the subsystem the only spawner, so the double-spawn footgun is closed in the same change that introduces the new spawner.
Tasks.
- [x] Driver-key bindings —
BindDriverKeys(called fromHandleVisualStateReadyalongside the Phase-2BuildPreview) subscribes toULoadoutComponent::OnLoadoutChanged(cockpit) andULoadoutModifierComponent::OnAssignmentsChanged(pilot), resolving both components off the localACradlPlayerState(newGetLocalPlayerState()helper). Idempotent (handle-validity guarded, re-entrant from possession churn). Seeds current state on bind (GetActiveLoadoutId()with theInitial != ActiveLoadoutIdLocalguard fromULoadoutVisualsComponent::TryBindLoadout; a syntheticHandleAssignmentsChanged()for the pilot), so a rig spawns immediately for an already-applied loadout/pilot; the change broadcast is the late-replication net. Compiled clean (2026-05-30). - [x] Cockpit spawn —
RequestCockpit/OnCockpitClassResolved: resolveULoadoutDefinition::PreviewVisualsActorforActiveLoadoutId(fast pathGetPrimaryAssetObject, else two-stage asyncLoadPrimaryAsset→RequestAsyncLoad); on resolve,SpawnRigIntoStagingspawns into the persistent world atGPreviewStagingSpawnPointRF_Transient(left visible —PRM_UseShowOnlyListisolates by allowlist, not by hide) and adds to the allowlist. Reuses theActiveLoadoutIdLocal-mismatch supersede guard from LoadoutVisualsComponent.cpp (if (WeakThis->...Local != LoadoutId) return; // superseded) — no new guard invented. Compiled clean (2026-05-30). - [x] Pilot spawn —
FindActivePilotDefinition(lifted from the retired component, keyed off the fixedCradlTags::Modifier_Pilotleaf — the subsystem isn't a placed instance, so the old editablePilotSlotTagbecomes a constant) →RequestPilot/OnPilotClassResolved: resolveULoadoutModifierDefinition::VisualsActor; async-load +SpawnRigIntoStaging+ allowlist with the same supersede guard, keyed off the modifier def id. Compiled clean (2026-05-30). - [x] Compose absent cases — null
PreviewVisualsActor→OnCockpitClassResolvedearly-returns (no cockpit, pilot-only against backdrop); noModifier.Pilotassigned →FindActivePilotDefinitionreturns null → no pilot rig (cockpit-only). Both-absent → empty backdrop. The subsystem composes whichever rigs resolve. Compiled clean (2026-05-30). - [x] Retire the pawn stub —
LoadoutModifierVisualsComponent.{h,cpp}deleted. The component's only purpose was the origin-spawn the subsystem now owns, so the whole class was removed (not gutted) — no dangling pawn component. Perfeedback_no_clean_up_later.md, every reference is in-scope and resolved: theACradlCharactersubobject construction + forward-decl + include + member (CradlCharacter.h/.cpp), and the stale spawner-name comments on ModifierVisualsActor.{h,cpp} + LoadoutModifierDefinition.h (now namingULoadoutPreviewSubsystem). TheAModifierVisualsActorrig itself is preserved (the subsystem spawns it). Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors. UBT invalidated the makefile on the removed source (LoadoutModifierVisualsComponent.{h,cpp});LoadoutPreviewSubsystem.cpp,CradlCharacter.cpp,ModifierVisualsActor.cpp,LoadoutModifierDefinition.cpprecompiled;UnrealEditor-CRADL.dll+UnrealEditor-CRADLEditor.dllboth linked. No warnings. Runtime verification needs aULoadoutDefinitionwithPreviewVisualsActorset and/or an assignedModifier.Pilotwith aVisualsActor; the rigs render against whatever lighting reaches the staging origin until the deferred/Game/Visuals/Preview/PL_LoadoutPreviewstaging level + lighting-channel pass land. Content caveat: if any Blueprint had subclassed the deletedULoadoutModifierVisualsComponent(none expected — pilot content derives fromAModifierVisualsActor, not the component), it would orphan on load; spot-check on first editor open.
Verification.
- Assign a loadout + pilot in PIE: both the cockpit and pilot rigs render live in the HUD PIP, lit by the staging level.
- Rapidly swap loadout several times in one frame window: no stranded/duplicate rig (the supersede guard drops in-flight loads for superseded keys).
- Clear the
Modifier.Pilotassignment → cockpit-only renders; clearPreviewVisualsActoron the loadout def → pilot-only renders. - Grep confirms the pawn-side spawn path is gone and nothing references
GetSpawnedActor.
Pre-work.
- LoadoutModifierVisualsComponent decoupling — before/with the spawn move, audit and remove the component's spawn responsibility and its
ACradlCharacterconstruction site. This is the canonical "retire an existing stub as part of the new owner's introduction" shape.
Footguns.
- Don't conflate
PreviewVisualsActor(cockpit, PIP) withVisualsActor(ship, gameplay world) — the in-world ship rig stays attached to the pawn viaCradl::Visuals::ResolveAnchor(VisualsAnchorProvider.h) and is untouched here. - Pilot rig carries its own skeletal mesh + anim BP in the BP subclass —
AModifierVisualsActorstays a generic C++ rig host (no skeletal-mesh member in C++), per theAVisualsRigActorWYSIWYG convention. - One owner for spawn — after this phase the subsystem is the only preview-rig spawner. Don't leave a second path on the pawn component.
Phase 5 — Capture Cadence: Live-While-Visible + Pause
Goal. The capture runs every frame while the PIP is visible so the pilot animates live, and hard-pauses (capture + rig tick) when hidden, so an off-screen PIP costs nothing.
Rationale. A polish-adjacent but pre-swap-gating concern: the live/pause behavior must exist before swap gating (Phase 6) can reason about "first frame of the new scene rendered" under an active capture. It's a discrete verb on top of the now-complete scene.
Tasks.
- [x] Live capture — the live-while-visible cadence is the
UCradlPilotPreviewSettings::bCaptureEveryFrame(defaulttrue) flag gating the Phase-3FTSTicker→CaptureNow, not the vendored component's ownbCaptureEveryFrame(which the vendoredUPocketCapture::Initializehard-setsfalse— PocketCapture.cpp L41). The deliberate deviation is documented at theTickCapturecall site +EnsureCapturePipelinein LoadoutPreviewSubsystem.cpp and on the settings field. Compiled clean (2026-05-30). - [x] Visibility signal —
UCradlLoadoutPreviewWidgetpushesULoadoutPreviewSubsystem::SetPreviewActive(bool)(new) from its ownOnVisibilityChanged(mapped: Visible / HitTestInvisible / SelfHitTestInvisible → on; Hidden / Collapsed → off), seeds the current visibility onNativeConstruct(a PIP that starts hidden builds paused), and pushesfalseonNativeDestruct(HUD torn down). CradlLoadoutPreviewWidget.{h,cpp}. Compiled clean (2026-05-30). - [x] Pause both —
SetPreviewActivesetsbPreviewVisible(the capture gate read inTickCapture, alongsidebCaptureActive) and toggles the rigs' tick viaSetRigTicksEnabled(actor tick + everybCanEverTickcomponent, so the anim-BP-drivingSkeletalMeshComponenttick halts). Both gate on the one signal; a rig that spawns while hidden spawns paused (SpawnRigIntoStaging). No-ops on an unchanged state. Compiled clean (2026-05-30). - [x] Texture streaming — satisfied structurally: the vendored
UPocketCaptureSubsystem::StreamThisFrameis called insideUPocketCapture::CaptureScene(PocketCapture.cpp L189), whichCaptureNowinvokes each active frame — so mip-pinning stays alive while visible and naturally releases when paused (the cost saving). The Phase-3 manualFTSTickerpath is untouched (only gated), and the vendored subsystem's ownTick(ETickableTickType::Always) is never disturbed. Compiled clean (2026-05-30). - [x] Cadence cap —
MaxCaptureFPS(settings,0 = uncapped) implemented inTickCapture: accumulate the tickerDeltaTime, capture once per1/MaxCaptureFPSbudget,Fmodthe remainder so the rate doesn't drift; the accumulator resets on a visibility flip so a resume captures promptly. (The Phase-3 note reserved this for Phase 5 — "layers the visibility-pause + FPS cap ontobCaptureActive.") Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors.LoadoutPreviewSubsystem.cpp+CradlLoadoutPreviewWidget.cpprecompiled (adaptive-unity-excluded),UnrealEditor-CRADL.dlllinked. No warnings. The capture is now visibility-gated:TickCapturerunsCaptureNowonly whilebCaptureActive && bPreviewVisible, the widget drivesbPreviewVisibleoff its own Slate visibility, and the rigs' anim tick pauses in lockstep. Runtime verification still needs the deferred/Game/Visuals/Preview/PL_LoadoutPreviewstaging level + a HUD WBP that bindsUCradlLoadoutPreviewWidget; until then the live/pause behavior is exercised via theDebug_SpawnPilotPreviewPrimitivefixture (which builds the pipeline withbPreviewVisibledefaultingtrue).
Verification.
- PIP visible: the pilot's idle/breathing anim plays live (not a frozen frame).
- Hide the PIP (open a full-screen HUD page or toggle off):
stat GPU/ capture-cost metric drops — capture and rig anim both halt. - Re-show: capture resumes at full mips (no blurry-then-sharpens artifact, because streaming stayed pinned while it was the active subject).
Footguns.
- Pause means pause both — capture-only-paused wastes anim eval; rig-only-paused shows a frozen frame at GPU cost.
bCaptureEveryFrame=trueis intentional — annotate the deviation at the call site.
Phase 6 — Initialization & Swap Gating
Goal. A single readiness boolean (FOnPreviewReadyChanged) covers both the begin-play build and mid-play loadout/pilot swaps, so the WBP can hold an "initializing" placeholder over the entire transition — no blank frames, half-spawned rig, or T-pose flash ever shows.
Rationale. The swap window is inherently racy (old rig destroyed while new soft classes async-load and spawn over several frames). Surfacing one subsystem-owned boolean — the only owner that knows async/spawn/first-capture state — lets the placeholder cover the transition deterministically and keeps the widget a pure view. Parallel-able with Phase 7 (both depend only on the completed scene from Phase 4 + cadence from Phase 5).
Tasks.
- [x] Not-ready on key change —
EnterNotReady()is called at the top of bothHandleLoadoutChangedandHandleAssignmentsChanged(the latter after its unchanged-pilot early-return so a no-op assignment never raises the placeholder), before theClearCockpitRig/ClearPilotRigteardown. Each handler marks its pipeline pending (bCockpitPending/bPilotPending) first. Compiled clean (2026-05-30). - [x] Ready on first capture of new scene — two-stage gate, not rig-spawn. Each pipeline calls
MarkCockpitSettled/MarkPilotSettledat every terminal point (rig spawned, class null, load failed, invalid key) →RequestReadyEvaluationschedules a next-tickEvaluateReadiness(deferral both fixes the synchronous-seed ordering race and gives the rig a tick to evaluate its first pose). When both pipelines are settled,EvaluateReadinesssetsbAwaitingReadyCapture; the next realCaptureNowinTickCaptureflips it and callsTryGoReady— so ready fires only once a posed frame is in the RT. Compiled clean (2026-05-30). - [x] Broadcast + getter —
SetPreviewReadyState(bool)is the single mutation point; it broadcastsOnPreviewReadyChangedonly on a change.IsPreviewReady()(declared Phase 1) returnsbPreviewReady. Compiled clean (2026-05-30). - [x] Widget re-broadcast — CradlLoadoutPreviewWidget.cpp:
NativeConstructsubscribes (AddUniqueDynamic) toOnPreviewReadyChanged→HandlePreviewReadyChanged→OnPreviewReadyStateChanged(bool)BIE, and seeds the current state on construct (not-ready until the subsystem confirms the first capture).NativeDestructunsubscribes.IsPreviewReady()is now a pure passthrough to the subsystem. Compiled clean (2026-05-30). - [x] One gate for both entry points — the begin-play build reaches the handlers through
BindDriverKeys(Phase 4, offHandleVisualStateReady), so it drives not-ready→ready through the sameEnterNotReady/settle/EvaluateReadiness/TryGoReadypath as a mid-play swap. No separate initial-build path. Compiled clean (2026-05-30). - [x] Minimum placeholder hold —
TryGoReadyhonorsUCradlPilotPreviewSettings::MinPlaceholderHoldSeconds(default0.35): if the new scene is captured before the floor elapses, it defers the reveal viaReadyHoldTimer→OnReadyHoldElapsed.EnterNotReadyre-stampsNotReadyStartTimeon every key change (only ever lengthens the hold) and cancels a pending deferred reveal. Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors.LoadoutPreviewSubsystem.cpp+CradlLoadoutPreviewWidget.cpprecompiled,UnrealEditor-CRADL.dlllinked. No warnings. The readiness state machine:EnterNotReady(broadcast not-ready, stamp min-hold, mark pending) → per-pipelineMark*Settled→ next-tickEvaluateReadiness(both settled? → await capture) →TickCaptureconfirms a posed frame →TryGoReady(min-hold floor) →SetPreviewReadyState(true)broadcast. Capture-confirm caveat: readiness progresses only while the PIP is actively capturing (live + visible) — a swap that completes while hidden stays not-ready until the PIP is shown again (correct: we never claim "ready" without having rendered the new scene). This assumesbCaptureEveryFrame=true(the default); the stills-mode fallback has no event-driven capture path yet, so it's out of scope. Runtime verification needs the deferred/Game/Visuals/Preview/PL_LoadoutPreviewstaging level + a HUD WBP bindingUCradlLoadoutPreviewWidgetthat authors a placeholder offOnPreviewReadyStateChanged.
Verification.
- Swap loadout or pilot mid-play: the WBP placeholder ("please wait" / spinner / frozen-last-frame, BP-authored) covers the entire transition; the new scene appears only once it's fully rendered — no blank/T-pose flash.
- A near-instant swap still shows the placeholder for the min-hold duration (no single-frame flicker).
- Begin-play: the placeholder is up from widget construct until the first scene's first captured frame.
Footguns.
- Ready fires on first capture, not rig spawn (T-pose flash otherwise).
- Enter not-ready before destroying the old rig (don't tear down then signal).
- The placeholder visual is the WBP's, not C++'s — the subsystem/widget emit only the boolean (CLAUDE.md).
- Default the min-hold non-zero (
feedback_loading_feel_min_hold.md).
Phase 7 — Event-Reaction Surface
Goal. The subsystem binds the local pawn's UCradlVisualStateComponent and forwards the curated reaction set to the pilot rig's BIE events (declared Phase 1), so the pilot reacts to xp/level/damage/combat and announces its own activation. C++ forwards, BP reacts.
Rationale. The last verb: the rigs exist (Phase 4), the picture is live (Phase 5), readiness is solid (Phase 6) — now the pilot comes alive to player state. Reuses the VSC adapter the HUD already binds, inheriting its solved late-bind story. Parallel-able with Phase 6.
Tasks.
- [x] Bind the local VSC —
BindReactionDelegates/UnbindReactionDelegateson the subsystem subscribeBoundVSC's reaction delegates. Bound fromHandleVisualStateReady(theCallWhenReadycallback, post-ready — not raw begin); idempotent viaAddUniqueDynamicso possession-churn /CallWhenReadyreplay can't double-bind. Re-bind onOnPossessedPawnChanged:RegisterReadyOnPawnnow unbinds the stale VSC's reactions before re-pointingBoundVSC. Unbound inDeinitialize(beforeBoundVSC.Reset()). This is theUCradlPlayerHUDWidgetbind flow lifted to the subsystem. Compiled clean (2026-05-30). - [x] Forward the curated set to the pilot rig BIEs (each handler null-guards
PilotRig.Get()— no pilot assigned / mid-swap simply drops the signal): - [x]
OnXPGained(SkillTag, Delta, NewXP)→NotifyXPGained(owner-only source — correct for a local PIP) - [x]
OnLevelUp(SkillTag, NewLevel)→NotifyLevelUp - [x]
OnAttributeChanged(Health, New, Old)→NotifyHealthChanged— filtered forUCradlAttributeSet::GetHealthAttribute()(Health auto-watched by VSC; the one channel carries every watched attribute, so Mana/etc. don't forward as "damage taken") - [x]
OnTagStateChanged(Status.InCombat, bActive)→NotifyCombatStateChanged— filtered forCradlTags::Status_InCombatin the handler (OnTagStateChangedis a firehose over Action/State/Status; never forwarded raw) - [x] Pilot-rig activation →
NotifyActivated— fired fromSpawnPilotRig(where the rig actually exists) gated onbReadyGatePassed(the VSC-ready gate, set inHandleVisualStateReadybefore any rig can spawn). Spawn is structurally post-ready (runs offBindDriverKeys→ off the ready callback); the gate makes "post-ready" explicit. Fires for the begin-play pilot and each mid-play swapped-in pilot rig (every fresh rig gets one entrance). Renamed fromNotifyGameplayStarted(+ flagbGameplayStarted→bReadyGatePassed) so the name matches the per-spawn behavior — user decision, 2026-05-30. - [x] Cockpit reactions (out of v1 default) — pilot-only, per Open Question #5. The cockpit rig's reaction BIEs are intentionally not wired (default cockpit = static backdrop); wire only if a content need surfaces. No code — deliberate scope decision.
- [x] Removed speculative
VisualStateReadyHandle— the Phase-1 shell scaffolded anFDelegateHandle"for the Phase-7 reaction binding," but VSC's reaction delegates are dynamic multicast (removed by(object, function), never by handle). Perfeedback_no_clean_up_later.md, dropped the now-unused member + corrected its comment in the same change. Compiled clean (2026-05-30).
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors.LoadoutPreviewSubsystem.{h,cpp}recompiled (adaptive-unity-excluded),UnrealEditor-CRADL.dlllinked. No warnings. The subsystem header gainedAttributeSet.h+GameplayTagContainer.h(for theFGameplayAttribute/FGameplayTagUFUNCTION handler params) and the.cppgainedAbilities/CradlAttributeSet.h(forGetHealthAttribute()). The reaction flow:HandleVisualStateReadysetsbGameplayStarted→BindDriverKeys→BindReactionDelegates(bindsOnXPGained/OnLevelUp/OnAttributeChanged/OnTagStateChangedtoBoundVSCviaAddUniqueDynamic); each handler filters the firehose signals and forwards toPilotRig's BIEs;NotifyActivatedfires inSpawnPilotRigpost-spawn. Runtime verification needs the deferred/Game/Visuals/Preview/PL_LoadoutPreviewstaging level, a HUD WBP bindingUCradlLoadoutPreviewWidget, and a pilotAModifierVisualsActorBP that authors the reaction BIEs (debug print / anim) to observe the forwards. Owner-only caveat (by design): on a remote proxy no PIP exists and the owner-onlyOnXPGained/OnLevelUppaths fire only locally — correct, not a bug to "fix."
Verification.
- Gain XP in PIE →
NotifyXPGainedfires on the pilot rig (observe via a BP debug print / anim reaction). - Take damage →
NotifyHealthChanged; enter/leave combat →NotifyCombatStateChangedtoggles; level up →NotifyLevelUp. - On a remote proxy: no PIP exists, so the owner-only
OnXPGained/OnLevelUppaths firing only locally is correct — not a bug to "fix."
Footguns.
OnXPGained/OnLevelUpare owner-only (CradlVisualStateComponent.h) — exactly right for a local-only PIP; don't add remote-proxy paths.OnTagStateChangedis a firehose — filter forStatus.InCombat.- Bind after ready, not raw begin (
CallWhenReady), perfeedback_umg_tooltip_delegate_init_timing.md. - Raw
FGameplayTagin a BIE payload needs a resolved name/icon only if the rig labels it (perfeedback_presentation_struct_resolve_tags.md); pure anim reactions need the tag alone — resolve only if the rig surfaces text.
Phase 8 — External Block / Feed-Offline Signal
Goal. The subsystem owns a generic, tag-keyed visual-block source-set that the pilot rig and the HUD widget both react to; the local player's death is the first cause that drives it. A BlueprintCallable SetVisualBlocked is the general toggle every cause (and the debug cheat) routes through. C++ forwards the boolean + reasons; BP authors the response (feed-offline overlay on the widget, eject anim on the rig).
Rationale. A second visibility axis orthogonal to readiness (Phase 6): readiness is system-driven and self-clearing; block is externally-driven and persists until its cause clears — folding death into the readiness gate would mis-read a respawn as "loading." Death is sourced PS-direct (not via VSC) mirroring the in-world ship rig's ULoadoutVisualsComponent::HandleDeathStateChanged. The tag source-set (vs a bool / reason-enum) is the user-chosen representation so overlapping causes ref-count correctly; built general-first (toggle + death + cheat) in one phase rather than death-only. See PILOT_SYSTEM.md "External Block / Feed-Offline".
Tasks.
- [x] Block tags —
Pilot.Preview.Block+Pilot.Preview.Block.Deathin DefaultGameplayTags.ini; theDeathleaf mirrored asCradlTags::Pilot_Preview_Block_Death(CradlGameplayTags.h/.cpp), referenced byHandleDeathStateChanged. Perfeedback_gameplay_tag_decl_minimal.mdonly the named leaf is C++-mirrored. Compiled clean (2026-05-30). - [x] Subsystem source-set + API —
FGameplayTagContainer ActiveBlockReasons+FOnPreviewBlockChanged(bool, FGameplayTagContainer)(BlueprintAssignable);SetVisualBlocked(Reason, bBlocked)(BlueprintCallable, membership-change-guarded viaHasTagExact→ no redundant broadcast),IsVisualBlocked(),GetActiveBlockReasons().BroadcastVisualBlockfans out to the delegate (→ widget) + the pilot rig BIE. LoadoutPreviewSubsystem.{h,cpp}. Compiled clean (2026-05-30). - [x] Death cause —
HandleDeathStateChangedtranslatesACradlPlayerState::OnDeathStateChanged!= Alive→SetVisualBlocked(Death, true/false). Bound + seeded inBindDriverKeys(PS-sourced like the driver keys; bound once, guarded against re-bind on churn/replay), unbound inDeinitializevia the same PS.DeathStateis already replicated — consumed locally, nothing added to the wire. Compiled clean (2026-05-30). - [x] Fan-out targets (actor + widget, per the requirement) — pilot rig
AModifierVisualsActor::NotifyVisualBlockChanged(bool, FGameplayTagContainer)BIE (ModifierVisualsActor.h); widgetUCradlLoadoutPreviewWidget::OnVisualBlockStateChangedBIE +IsVisualBlocked()pure getter +HandleVisualBlockChangedsubscriber, subscribed/seeded inNativeConstruct, unsubscribed inNativeDestruct(mirrors the Phase-6 readiness re-broadcast). CradlLoadoutPreviewWidget.{h,cpp}. Compiled clean (2026-05-30). - [x] Seed a fresh rig —
SpawnPilotRigre-emitsNotifyVisualBlockChanged(true, …)onto a newly-spawned pilot rig whenActiveBlockReasonsis non-empty, so a pilot swapped in mid-block shows offline immediately (the broadcast only reaches rigs that existed at toggle time). Compiled clean (2026-05-30). - [x] General toggle cheat —
Debug_TogglePilotPreviewBlockexec on CradlPlayerController (#if !UE_BUILD_SHIPPINGbody) flips theDeathreason on the aggregate to exercise the feed-offline visuals without dying. Diagnostic at Warning (perfeedback_log_level_warning_for_diagnostics.md). Compiled clean (2026-05-30). - [x] Cockpit excluded — pilot rig + widget only, per Open Question #5; the cockpit stays a static backdrop unless a content need surfaces. No code — scope decision.
Open Decision — RESOLVED (user, 2026-05-30). Reason representation = gameplay-tag source-set (over bool / reason-enum) so overlapping causes ref-count; scope = general toggle + death cause together (over death-only), landing the BlueprintCallable SetVisualBlocked + the cheat now.
Build note (2026-05-30):
Build.bat CRADLEditor Win64 Developmentlinks exit-0; UHT ran with-WarningsAsErrors.CradlGameplayTags.cpp,ModifierVisualsActor.cpp,CradlLoadoutPreviewWidget.cpp,LoadoutPreviewSubsystem.cpp,CradlPlayerController.cppcompiled (adaptive-unity-excluded);UnrealEditor-CRADL.dll+UnrealEditor-CRADLEditor.dllboth linked. No warnings. Block is orthogonal to capture — it never touchesSetPreviewActive, so an eject anim renders live. Runtime verification needs the deferred/Game/Visuals/Preview/PL_LoadoutPreviewstaging level + a HUD WBP bindingUCradlLoadoutPreviewWidgetthat authorsOnVisualBlockStateChanged, and a pilotAModifierVisualsActorBP that authorsNotifyVisualBlockChanged; until then it's exercised viaDebug_TogglePilotPreviewBlockagainst the fixture.
Verification.
- Die in PIE → the pilot rig's
NotifyVisualBlockChanged(true, {Death})fires (observe via a BP debug print / eject anim) and the widget'sOnVisualBlockStateChanged(true, …)raises the feed-offline overlay; respawn → both clear. Debug_TogglePilotPreviewBlockflips the same visuals on/off without dying.- Swap the pilot while blocked → the freshly-spawned rig shows offline immediately (seed-on-spawn).
- The block does not pause capture: a blocked PIP still animates (an eject anim plays).
- On a remote proxy: no PIP exists; nothing to verify (local-only).
Footguns.
- Block ≠ capture-pause — never route it through
SetPreviewActive(an eject anim needs the capture live). - Seed on both bind and rig-spawn — else a mid-block swap-in shows live instead of offline.
- The offline/eject visual is the WBP/rig BP's, not C++'s — the subsystem emits only the boolean + reason container (CLAUDE.md).
- Death is PS-sourced, bound once — don't re-point it on possession churn (PS outlives pawns); that's why it lives in
BindDriverKeys, not the VSC-keyedBindReactionDelegates.
Open Questions (carried from the contract)
- PocketWorlds clean port (Open Question #1) — resolved by Phase 0's verification gate. If the port fails to compile against 5.4, the whole build is blocked.
ULoadoutModifierDefinitionvalidator (Open Question #2) — RESOLVED.UCradlLoadoutModifierDefinitionValidatoradded in this work (Phase 1 task). ValidatesDisplayName/Icon/SlotTag/VisualsActor; auto-registers viaUEditorValidatorBase.- PIP interactivity (Open Question #3) — out of v1 scope. Click-to-rotate, click-to-open the swap panel (LoadoutModifierSwapAbility.h), hover tooltips need their own design (input on a render-target-backed widget). No phase; follow-up if wanted.
- Render-target sizing / cadence cap / placeholder hold / settings home (Open Question #4) — touched in Phases 1, 3, 5, 6. Contract default = subsystem constants; adopt
UCradlPilotPreviewSettingsonly if hardware tuning warrants. Not resolved here. - Cockpit reaction surface (Open Question #5) — pilot-only default in Phase 7; cockpit reactions deferred unless a content need surfaces.