0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI PILOT_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
PILOT_IMPLEMENTATION.md 6717 words ~31 min read Updated 2026-07-03

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 ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, UFUNCTION declarations are unconditional; only the .cpp body is wrapped in the guard (never combine UFUNCTION + #if in a header).
  • Per CLAUDE.md "validators in lockstep": any phase that touches ULoadoutDefinition (adding PreviewVisualsActor) updates UCradlLoadoutDefinitionValidator in the same change. Tag checks in the validator resolve via RequestGameplayTag strings, never CradlTags::X (per feedback_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.bat call (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_Transient and adds zero replicated state. Every spawned actor is bReplicates=false; the subsystem builds only where a local ULocalPlayer exists. 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 to Plugins/PocketWorlds/ (per project_pocketworlds_vendoring.md).
  • [x] Register the plugin — add the PocketWorlds entry to the Plugins array in CRADL.uproject (Enabled: true; .uplugin is EnabledByDefault:false so explicit enable required).
  • [x] Declare the module dependency — added PocketWorlds to PublicDependencyModuleNames in CRADL.Build.cs.
  • [x] Confirm content importsM_PocketCaptureMasked and 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.dll all link clean. Wart: PocketLevelInstance.cpp emits C4996 — ULevelStreaming::ECurrentState/GetCurrentState deprecated in 5.4 (use ELevelStreamingState/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 PocketWorlds in the enabled-plugins list; no missing-redirector / failed-content warnings for M_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 existing VisualsActor TSoftClassPtr<ALoadoutVisualsActor> declaration. Null = no cockpit (pilot renders against bare backdrop). Forward-declare ALoadoutPreviewVisualsActor. 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 from ALoadoutPreviewVisualsActor. Null is valid (cockpit optional). Mirrors the VisualsActor dangling check (_C-strip) + a LoadSynchronousIsChildOf type 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] UCradlLoadoutModifierDefinitionValidator mirrors UCradlLoadoutDefinitionValidator: DisplayName non-empty, Icon set + resolves, SlotTag valid + under Modifier.* (root resolved via RequestGameplayTag("Modifier") string — never CradlTags::X, per feedback_native_tag_no_cross_module_export.md), VisualsActor resolves when set (_C-strip). Null StatsEffect/VisualsActor valid. Auto-registers via UEditorValidatorBase discovery (no manual registration). Compiled clean (2026-05-30).
  • [x] Add ULoadoutModifierDefinition to the CLAUDE.md "currently validated" list.
  • [x] Cockpit rig shell — Source/CRADL/Loadout/LoadoutPreviewVisualsActor.{h,cpp} (new):
  • [x] class CRADL_API ALoadoutPreviewVisualsActor : public AVisualsRigActor. Reaffirms bReplicates = false in 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, two TSharedPtr<FStreamableHandle>, three FDelegateHandle, bPreviewReady) — UObject holders UPROPERTY(Transient). Initialize/Deinitialize overrides are empty shells. Compiled clean (2026-05-30).
  • [x] Readiness delegate — declared FOnPreviewReadyChanged (new) DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(bool bReady) (BlueprintAssignable) + bool IsPreviewReady() const getter 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. A UPROPERTY(meta=(BindWidget)) TObjectPtr<UImage> PreviewImage;. Declared UFUNCTION(BlueprintImplementableEvent) void OnPreviewReadyStateChanged(bool bReady); and UFUNCTION(BlueprintPure) bool IsPreviewReady() const; (cpp returns false until 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 the PlayerHUD/MessageLog optional-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 as NotifyGameplayStarted() in this phase; renamed in Phase 7 to match its per-spawn behavior) — signatures aligned to the VSC forwards in Phase 7's table. Mirrors ALoadoutVisualsActor::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"), mirroring UCradlCombatSettings. Holds RenderTargetSize (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 Development links 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 -WarningsAsErrors so the headers + BIE/delegate reflection are clean. UnrealEditor-CRADL.dll + UnrealEditor-CRADLEditor.dll both link. No warnings.

Verification.

  • Compile clean — done (Claude build above; links exit-0).
  • (user, runtime) In the editor, open a ULoadoutDefinition asset: the new PreviewVisualsActor field appears. Set it to a non-ALoadoutPreviewVisualsActor class → 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 UCradlLoadoutPreviewWidget child named LoadoutPreview binds 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 UFUNCTION with #if !UE_BUILD_SHIPPING in the BIE declarations (CLAUDE.md) — declare unconditionally.
  • Don't name any widget local Slot — it shadows UWidget::Slot (per feedback_slot_shadows_uwidget.md); use PreviewImage/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 gateShouldCreateSubsystem restricts to IsGameWorld() (no editor preview / inactive worlds); OnWorldBeginPlay early-outs on NM_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 triggerOnWorldBeginPlayEstablishReadyTrigger: resolves the local PC, binds APlayerController::OnPossessedPawnChanged, and registers UCradlVisualStateComponent::CallWhenReady (CradlVisualStateComponent.h) against the possessed pawn; the ready callback runs BuildPreview. A short re-arm timer covers the network-client case where the local PC hasn't replicated in by world-begin (a UWorldSubsystem has no OnRep_PlayerState-style hook). Re-registers for fresh pawns via OnPossessedPawnChanged. Per PILOT_SYSTEM.md "Gameplay-Start Hook". Compiled clean (2026-05-30).
  • [x] Pocket instance buildBuildPreview/FinishBuild: resolves the local ULocalPlayer, async-loads the staging UPocketLevel descriptor (new UCradlPilotPreviewSettings::PreviewPocketLevel soft ref — no synchronous loads per CLAUDE.md; resident-fast-path skips the hop), then calls UPocketLevelSubsystem::GetOrCreatePocketLevelFor (vendored) at a far off-origin spawn point ((0,0,10 km) — large-but-not-extreme) and StreamIns it. Idempotent: guarded by PocketInstance + the in-flight load handle so re-entry from CallWhenReady/possession churn never double-builds. Compiled clean (2026-05-30).
  • [x] TeardownDeinitialize clears the retry timer, unbinds OnPossessedPawnChanged, cancels the in-flight load, and StreamOuts the pocket instance. (The vendored UPocketLevelInstance stays owned by UPocketLevelSubsystem — no remove API — but StreamOut unloads 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_SHIPPING body only: Debug_DumpPilotPreview (exec UFUNCTIONs can't carry the dotted Cradl.Pilot.DumpPreviewState form; named to match the file's Debug_* 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's GetDebugStateString(). Diagnostic at Warning level (per feedback_log_level_warning_for_diagnostics.md). Compiled clean (2026-05-30).

Build note (2026-05-30): Build.bat CRADLEditor Win64 Development links exit-0; UHT ran with -WarningsAsErrors. LoadoutPreviewSubsystem.cpp + CradlPlayerController.cpp compiled, UnrealEditor-CRADL.dll linked. New settings field UCradlPilotPreviewSettings::PreviewPocketLevel (TSoftObjectPtr<UPocketLevel>, category "Staging") is the staging-level home. Runtime verification is gated on the deferred content asset /Game/Visuals/Preview/PL_LoadoutPreview existing and being assigned in Project Settings → CRADL → Pilot Preview; until then BuildPreview no-ops with a Warning and Debug_DumpPilotPreview reports built=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 UPocketLevel content 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 UPocketLevel data asset + its template ULevel (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 ACradlPlayerState and its loadout/modifier components arrive after world begin on clients; rely on CallWhenReady (fires immediately if already ready) + the OnPlayerStateReplicated re-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 buildCallWhenReady + 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. Vendored UPocketCapture is Abstract, so a minimal concrete subclass ULoadoutPreviewCapture (new — LoadoutPreviewCapture.{h,cpp}) was added; it exposes CaptureAllowlist(actors) over the protected CaptureScene so ShowOnlyActors is an explicit list, not the capture target's attachment hierarchy (the vanilla CaptureDiffuse path). PRM_UseShowOnlyList is set by the vendored Initialize; the subsystem sizes the RT from UCradlPilotPreviewSettings::RenderTargetSize (384) and pulls the diffuse RT up-front. The capture needs a UCameraComponent-bearing target → the subsystem spawns a framing ACameraActor (SpawnCaptureCamera) at the staging origin. Compiled clean (2026-05-30).
  • [x] Allowlist plumbingAddActorToAllowlist adds to an authoritative TArray<TWeakObjectPtr<AActor>>; CaptureNow builds the live list (compacting dead weak refs — rig churn / destroyed fixture) and calls CaptureAllowlist. 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 (TickCaptureCaptureNow), the PocketWorlds-idiomatic manual per-frame capture (its own UPocketCaptureSubsystem uses the same ticker), which is also what keeps StreamThisFrame mip-pinning alive — so it can't be the component's own bCaptureEveryFrame. Gated on bCaptureActive (set when the pipeline builds) + UCradlPilotPreviewSettings::bCaptureEveryFrame. Phase 5 layers the visibility-pause + FPS cap onto bCaptureActive — nothing here is thrown away. Compiled clean (2026-05-30).
  • [x] HUD widget binding — CradlLoadoutPreviewWidget.cpp: NativeConstructGetPreviewSubsystem()->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 dotted Cradl.Pilot.SpawnDebugPrimitive form — named to match the file's Debug_* convention, as with Phase 2's Debug_DumpPilotPreview). #if !UE_BUILD_SHIPPING body on CradlPlayerControllerULoadoutPreviewSubsystem::SpawnDebugPrimitive (also shipping-stripped): ensures the pipeline, spawns an engine sphere AStaticMeshActor at 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 Development links exit-0; UHT ran with -WarningsAsErrors. New TU LoadoutPreviewCapture.cpp + edited LoadoutPreviewSubsystem.cpp, CradlLoadoutPreviewWidget.cpp, CradlPlayerController.cpp all compiled; UnrealEditor-CRADL.dll linked. 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 a UCradlLoadoutPreviewWidget named LoadoutPreview with a UImage child named PreviewImage. Until the staging level is assigned the pocket instance no-ops (Phase 2), but the capture pipeline + brush still build lazily, so Debug_SpawnPilotPreviewPrimitive will render the sphere against whatever lighting reaches the staging origin.

Verification.

  • Run Debug_SpawnPilotPreviewPrimitive: the debug mesh appears live in the HUD PIP UImage, 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_UseShowOnlyList isolation holds).
  • A HUD without the optional widget still runs (the capture pipeline is never built — lazy on the widget's RT request).

Footguns.

  • PRM_UseShowOnlyList is 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 bindingsBindDriverKeys (called from HandleVisualStateReady alongside the Phase-2 BuildPreview) subscribes to ULoadoutComponent::OnLoadoutChanged (cockpit) and ULoadoutModifierComponent::OnAssignmentsChanged (pilot), resolving both components off the local ACradlPlayerState (new GetLocalPlayerState() helper). Idempotent (handle-validity guarded, re-entrant from possession churn). Seeds current state on bind (GetActiveLoadoutId() with the Initial != ActiveLoadoutIdLocal guard from ULoadoutVisualsComponent::TryBindLoadout; a synthetic HandleAssignmentsChanged() 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 spawnRequestCockpit/OnCockpitClassResolved: resolve ULoadoutDefinition::PreviewVisualsActor for ActiveLoadoutId (fast path GetPrimaryAssetObject, else two-stage async LoadPrimaryAssetRequestAsyncLoad); on resolve, SpawnRigIntoStaging spawns into the persistent world at GPreviewStagingSpawnPoint RF_Transient (left visiblePRM_UseShowOnlyList isolates by allowlist, not by hide) and adds to the allowlist. Reuses the ActiveLoadoutIdLocal-mismatch supersede guard from LoadoutVisualsComponent.cpp (if (WeakThis->...Local != LoadoutId) return; // superseded) — no new guard invented. Compiled clean (2026-05-30).
  • [x] Pilot spawnFindActivePilotDefinition (lifted from the retired component, keyed off the fixed CradlTags::Modifier_Pilot leaf — the subsystem isn't a placed instance, so the old editable PilotSlotTag becomes a constant) → RequestPilot/OnPilotClassResolved: resolve ULoadoutModifierDefinition::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 PreviewVisualsActorOnCockpitClassResolved early-returns (no cockpit, pilot-only against backdrop); no Modifier.Pilot assigned → FindActivePilotDefinition returns 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. Per feedback_no_clean_up_later.md, every reference is in-scope and resolved: the ACradlCharacter subobject construction + forward-decl + include + member (CradlCharacter.h/.cpp), and the stale spawner-name comments on ModifierVisualsActor.{h,cpp} + LoadoutModifierDefinition.h (now naming ULoadoutPreviewSubsystem). The AModifierVisualsActor rig itself is preserved (the subsystem spawns it). Compiled clean (2026-05-30).

Build note (2026-05-30): Build.bat CRADLEditor Win64 Development links exit-0; UHT ran with -WarningsAsErrors. UBT invalidated the makefile on the removed source (LoadoutModifierVisualsComponent.{h,cpp}); LoadoutPreviewSubsystem.cpp, CradlCharacter.cpp, ModifierVisualsActor.cpp, LoadoutModifierDefinition.cpp recompiled; UnrealEditor-CRADL.dll + UnrealEditor-CRADLEditor.dll both linked. No warnings. Runtime verification needs a ULoadoutDefinition with PreviewVisualsActor set and/or an assigned Modifier.Pilot with a VisualsActor; the rigs render against whatever lighting reaches the staging origin until the deferred /Game/Visuals/Preview/PL_LoadoutPreview staging level + lighting-channel pass land. Content caveat: if any Blueprint had subclassed the deleted ULoadoutModifierVisualsComponent (none expected — pilot content derives from AModifierVisualsActor, 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.Pilot assignment → cockpit-only renders; clear PreviewVisualsActor on 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 ACradlCharacter construction 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) with VisualsActor (ship, gameplay world) — the in-world ship rig stays attached to the pawn via Cradl::Visuals::ResolveAnchor (VisualsAnchorProvider.h) and is untouched here.
  • Pilot rig carries its own skeletal mesh + anim BP in the BP subclassAModifierVisualsActor stays a generic C++ rig host (no skeletal-mesh member in C++), per the AVisualsRigActor WYSIWYG 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 (default true) flag gating the Phase-3 FTSTickerCaptureNow, not the vendored component's own bCaptureEveryFrame (which the vendored UPocketCapture::Initialize hard-sets falsePocketCapture.cpp L41). The deliberate deviation is documented at the TickCapture call site + EnsureCapturePipeline in LoadoutPreviewSubsystem.cpp and on the settings field. Compiled clean (2026-05-30).
  • [x] Visibility signalUCradlLoadoutPreviewWidget pushes ULoadoutPreviewSubsystem::SetPreviewActive(bool) (new) from its own OnVisibilityChanged (mapped: Visible / HitTestInvisible / SelfHitTestInvisible → on; Hidden / Collapsed → off), seeds the current visibility on NativeConstruct (a PIP that starts hidden builds paused), and pushes false on NativeDestruct (HUD torn down). CradlLoadoutPreviewWidget.{h,cpp}. Compiled clean (2026-05-30).
  • [x] Pause bothSetPreviewActive sets bPreviewVisible (the capture gate read in TickCapture, alongside bCaptureActive) and toggles the rigs' tick via SetRigTicksEnabled (actor tick + every bCanEverTick component, so the anim-BP-driving SkeletalMeshComponent tick 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::StreamThisFrame is called inside UPocketCapture::CaptureScene (PocketCapture.cpp L189), which CaptureNow invokes each active frame — so mip-pinning stays alive while visible and naturally releases when paused (the cost saving). The Phase-3 manual FTSTicker path is untouched (only gated), and the vendored subsystem's own Tick (ETickableTickType::Always) is never disturbed. Compiled clean (2026-05-30).
  • [x] Cadence capMaxCaptureFPS (settings, 0 = uncapped) implemented in TickCapture: accumulate the ticker DeltaTime, capture once per 1/MaxCaptureFPS budget, Fmod the 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 onto bCaptureActive.") Compiled clean (2026-05-30).

Build note (2026-05-30): Build.bat CRADLEditor Win64 Development links exit-0; UHT ran with -WarningsAsErrors. LoadoutPreviewSubsystem.cpp + CradlLoadoutPreviewWidget.cpp recompiled (adaptive-unity-excluded), UnrealEditor-CRADL.dll linked. No warnings. The capture is now visibility-gated: TickCapture runs CaptureNow only while bCaptureActive && bPreviewVisible, the widget drives bPreviewVisible off its own Slate visibility, and the rigs' anim tick pauses in lockstep. Runtime verification still needs the deferred /Game/Visuals/Preview/PL_LoadoutPreview staging level + a HUD WBP that binds UCradlLoadoutPreviewWidget; until then the live/pause behavior is exercised via the Debug_SpawnPilotPreviewPrimitive fixture (which builds the pipeline with bPreviewVisible defaulting true).

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=true is 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 changeEnterNotReady() is called at the top of both HandleLoadoutChanged and HandleAssignmentsChanged (the latter after its unchanged-pilot early-return so a no-op assignment never raises the placeholder), before the ClearCockpitRig/ClearPilotRig teardown. 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/MarkPilotSettled at every terminal point (rig spawned, class null, load failed, invalid key) → RequestReadyEvaluation schedules a next-tick EvaluateReadiness (deferral both fixes the synchronous-seed ordering race and gives the rig a tick to evaluate its first pose). When both pipelines are settled, EvaluateReadiness sets bAwaitingReadyCapture; the next real CaptureNow in TickCapture flips it and calls TryGoReady — so ready fires only once a posed frame is in the RT. Compiled clean (2026-05-30).
  • [x] Broadcast + getterSetPreviewReadyState(bool) is the single mutation point; it broadcasts OnPreviewReadyChanged only on a change. IsPreviewReady() (declared Phase 1) returns bPreviewReady. Compiled clean (2026-05-30).
  • [x] Widget re-broadcast — CradlLoadoutPreviewWidget.cpp: NativeConstruct subscribes (AddUniqueDynamic) to OnPreviewReadyChangedHandlePreviewReadyChangedOnPreviewReadyStateChanged(bool) BIE, and seeds the current state on construct (not-ready until the subsystem confirms the first capture). NativeDestruct unsubscribes. 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, off HandleVisualStateReady), so it drives not-ready→ready through the same EnterNotReady/settle/EvaluateReadiness/TryGoReady path as a mid-play swap. No separate initial-build path. Compiled clean (2026-05-30).
  • [x] Minimum placeholder holdTryGoReady honors UCradlPilotPreviewSettings::MinPlaceholderHoldSeconds (default 0.35): if the new scene is captured before the floor elapses, it defers the reveal via ReadyHoldTimerOnReadyHoldElapsed. EnterNotReady re-stamps NotReadyStartTime on 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 Development links exit-0; UHT ran with -WarningsAsErrors. LoadoutPreviewSubsystem.cpp + CradlLoadoutPreviewWidget.cpp recompiled, UnrealEditor-CRADL.dll linked. No warnings. The readiness state machine: EnterNotReady (broadcast not-ready, stamp min-hold, mark pending) → per-pipeline Mark*Settled → next-tick EvaluateReadiness (both settled? → await capture) → TickCapture confirms 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 assumes bCaptureEveryFrame=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_LoadoutPreview staging level + a HUD WBP binding UCradlLoadoutPreviewWidget that authors a placeholder off OnPreviewReadyStateChanged.

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 VSCBindReactionDelegates/UnbindReactionDelegates on the subsystem subscribe BoundVSC's reaction delegates. Bound from HandleVisualStateReady (the CallWhenReady callback, post-ready — not raw begin); idempotent via AddUniqueDynamic so possession-churn / CallWhenReady replay can't double-bind. Re-bind on OnPossessedPawnChanged: RegisterReadyOnPawn now unbinds the stale VSC's reactions before re-pointing BoundVSC. Unbound in Deinitialize (before BoundVSC.Reset()). This is the UCradlPlayerHUDWidget bind 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)NotifyHealthChangedfiltered for UCradlAttributeSet::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)NotifyCombatStateChangedfiltered for CradlTags::Status_InCombat in the handler (OnTagStateChanged is a firehose over Action/State/Status; never forwarded raw)
  • [x] Pilot-rig activation → NotifyActivated — fired from SpawnPilotRig (where the rig actually exists) gated on bReadyGatePassed (the VSC-ready gate, set in HandleVisualStateReady before any rig can spawn). Spawn is structurally post-ready (runs off BindDriverKeys → 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 from NotifyGameplayStarted (+ flag bGameplayStartedbReadyGatePassed) 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 an FDelegateHandle "for the Phase-7 reaction binding," but VSC's reaction delegates are dynamic multicast (removed by (object, function), never by handle). Per feedback_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 Development links exit-0; UHT ran with -WarningsAsErrors. LoadoutPreviewSubsystem.{h,cpp} recompiled (adaptive-unity-excluded), UnrealEditor-CRADL.dll linked. No warnings. The subsystem header gained AttributeSet.h + GameplayTagContainer.h (for the FGameplayAttribute/FGameplayTag UFUNCTION handler params) and the .cpp gained Abilities/CradlAttributeSet.h (for GetHealthAttribute()). The reaction flow: HandleVisualStateReady sets bGameplayStartedBindDriverKeysBindReactionDelegates (binds OnXPGained/OnLevelUp/OnAttributeChanged/OnTagStateChanged to BoundVSC via AddUniqueDynamic); each handler filters the firehose signals and forwards to PilotRig's BIEs; NotifyActivated fires in SpawnPilotRig post-spawn. Runtime verification needs the deferred /Game/Visuals/Preview/PL_LoadoutPreview staging level, a HUD WBP binding UCradlLoadoutPreviewWidget, and a pilot AModifierVisualsActor BP 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-only OnXPGained/OnLevelUp paths fire only locally — correct, not a bug to "fix."

Verification.

  • Gain XP in PIE → NotifyXPGained fires on the pilot rig (observe via a BP debug print / anim reaction).
  • Take damage → NotifyHealthChanged; enter/leave combat → NotifyCombatStateChanged toggles; level up → NotifyLevelUp.
  • On a remote proxy: no PIP exists, so the owner-only OnXPGained/OnLevelUp paths firing only locally is correct — not a bug to "fix."

Footguns.

  • OnXPGained/OnLevelUp are owner-only (CradlVisualStateComponent.h) — exactly right for a local-only PIP; don't add remote-proxy paths.
  • OnTagStateChanged is a firehose — filter for Status.InCombat.
  • Bind after ready, not raw begin (CallWhenReady), per feedback_umg_tooltip_delegate_init_timing.md.
  • Raw FGameplayTag in a BIE payload needs a resolved name/icon only if the rig labels it (per feedback_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 tagsPilot.Preview.Block + Pilot.Preview.Block.Death in DefaultGameplayTags.ini; the Death leaf mirrored as CradlTags::Pilot_Preview_Block_Death (CradlGameplayTags.h/.cpp), referenced by HandleDeathStateChanged. Per feedback_gameplay_tag_decl_minimal.md only the named leaf is C++-mirrored. Compiled clean (2026-05-30).
  • [x] Subsystem source-set + APIFGameplayTagContainer ActiveBlockReasons + FOnPreviewBlockChanged(bool, FGameplayTagContainer) (BlueprintAssignable); SetVisualBlocked(Reason, bBlocked) (BlueprintCallable, membership-change-guarded via HasTagExact → no redundant broadcast), IsVisualBlocked(), GetActiveBlockReasons(). BroadcastVisualBlock fans out to the delegate (→ widget) + the pilot rig BIE. LoadoutPreviewSubsystem.{h,cpp}. Compiled clean (2026-05-30).
  • [x] Death causeHandleDeathStateChanged translates ACradlPlayerState::OnDeathStateChanged != AliveSetVisualBlocked(Death, true/false). Bound + seeded in BindDriverKeys (PS-sourced like the driver keys; bound once, guarded against re-bind on churn/replay), unbound in Deinitialize via the same PS. DeathState is 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); widget UCradlLoadoutPreviewWidget::OnVisualBlockStateChanged BIE + IsVisualBlocked() pure getter + HandleVisualBlockChanged subscriber, subscribed/seeded in NativeConstruct, unsubscribed in NativeDestruct (mirrors the Phase-6 readiness re-broadcast). CradlLoadoutPreviewWidget.{h,cpp}. Compiled clean (2026-05-30).
  • [x] Seed a fresh rigSpawnPilotRig re-emits NotifyVisualBlockChanged(true, …) onto a newly-spawned pilot rig when ActiveBlockReasons is 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 cheatDebug_TogglePilotPreviewBlock exec on CradlPlayerController (#if !UE_BUILD_SHIPPING body) flips the Death reason on the aggregate to exercise the feed-offline visuals without dying. Diagnostic at Warning (per feedback_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 Development links exit-0; UHT ran with -WarningsAsErrors. CradlGameplayTags.cpp, ModifierVisualsActor.cpp, CradlLoadoutPreviewWidget.cpp, LoadoutPreviewSubsystem.cpp, CradlPlayerController.cpp compiled (adaptive-unity-excluded); UnrealEditor-CRADL.dll + UnrealEditor-CRADLEditor.dll both linked. No warnings. Block is orthogonal to capture — it never touches SetPreviewActive, so an eject anim renders live. Runtime verification needs the deferred /Game/Visuals/Preview/PL_LoadoutPreview staging level + a HUD WBP binding UCradlLoadoutPreviewWidget that authors OnVisualBlockStateChanged, and a pilot AModifierVisualsActor BP that authors NotifyVisualBlockChanged; until then it's exercised via Debug_TogglePilotPreviewBlock against 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's OnVisualBlockStateChanged(true, …) raises the feed-offline overlay; respawn → both clear.
  • Debug_TogglePilotPreviewBlock flips 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-keyed BindReactionDelegates.

Open Questions (carried from the contract)

  1. 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.
  2. ULoadoutModifierDefinition validator (Open Question #2)RESOLVED. UCradlLoadoutModifierDefinitionValidator added in this work (Phase 1 task). Validates DisplayName/Icon/SlotTag/VisualsActor; auto-registers via UEditorValidatorBase.
  3. 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.
  4. Render-target sizing / cadence cap / placeholder hold / settings home (Open Question #4) — touched in Phases 1, 3, 5, 6. Contract default = subsystem constants; adopt UCradlPilotPreviewSettings only if hardware tuning warrants. Not resolved here.
  5. Cockpit reaction surface (Open Question #5)pilot-only default in Phase 7; cockpit reactions deferred unless a content need surfaces.