CRADL Pilot Visual Representation System
Companion to ARCHITECTURE.md, STAT_PIPELINE.md (the stat side of pilots/loadout-modifiers), and PLAYFLOW_SYSTEM.md (the "gameplay start" hook). This document is the contract the pilot's visual representation must satisfy — its capture mechanism, off-screen staging, scene composition, event-reaction surface, HUD mount, and replication shape. The stat/unlock/select/persist plumbing for loadout-modifiers ("pilots") already exists and is out of scope here — see STAT_PIPELINE.md "Pilot (loadout modifier)". What's here does not change without a deliberate edit to this file.
North Star
A "pilot" is the in-fiction face of an assigned loadout-modifier. Its visual representation is a live, animated picture-in-picture (PIP) mounted on the HUD: a per-loadout cockpit scene with the assigned pilot skeletal mesh inside it, rendered from an isolated off-screen world and reacting to the local player's moment-to-moment state (xp, level-up, damage, combat). The entire visual is local-only — never replicated; each client renders its own pilot from already-replicated keys. The pilot system's data (slot assignment, stat GEs, persistence) is done; this contract adds only the picture. It reuses every visuals pattern the loadout/enemy rigs established — the AVisualsRigActor rig host, the driver-key async spawn, the UCradlVisualStateComponent event surface, the BindWidgetOptional HUD child — it adds a capture pipeline, not a foundation.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| How is the pilot rendered into the HUD? | Vendored PocketWorlds plugin: USceneCaptureComponent2D (allowlist) → UTextureRenderTarget2D → UImage brush on a HUD widget. |
Capture Mechanism |
| How is the rig kept out of the play space? | It lives in a UPocketLevelInstance — a ULevelStreamingDynamic staged off-origin, bClientOnlyVisible, with its own lighting/post. |
Off-Screen Staging |
| What composes the PIP scene? | Two locally-spawned rigs: a cockpit rig keyed off ULoadoutComponent::ActiveLoadoutId + a pilot rig keyed off the Modifier.Pilot assignment. |
Scene Composition |
| Who owns the pocket level + capture? | ULoadoutPreviewSubsystem (new) — a UWorldSubsystem built only for the local player. |
Ownership & Lifecycle |
| What happens to the existing pawn-side spawn stub? | ULoadoutModifierVisualsComponent's origin-spawn is retired/absorbed into the subsystem; its BP-hook pattern is preserved on the rigs. |
Ownership & Lifecycle |
| How does the pilot react to xp/level/damage/combat? | The subsystem binds the local pawn's UCradlVisualStateComponent and forwards to BIE events on the rig (anim-BP driven). |
Event-Reaction Surface |
| How does the feed react to death (or any "offline" cause)? | A generic, tag-keyed visual-block source-set on the subsystem. Death binds it via ACradlPlayerState::OnDeathStateChanged; the WBP draws a feed-offline overlay and/or the rig plays an eject anim. |
External Block / Feed-Offline |
| What is the "gameplay start" trigger? | UCradlVisualStateComponent::OnVisualStateReady / CallWhenReady on the local pawn — the same readiness gate the HUD uses. |
Gameplay-Start Hook |
| How alive is the pilot? | Live while visible: bCaptureEveryFrame=true while the PIP is shown; capture + rig tick paused/torn down when hidden. |
Capture Cadence |
| How does the WBP avoid swap pop-in? | Subsystem OnPreviewReadyChanged(bool) → widget BIE; the WBP shows its own "initializing" placeholder until the new scene's first frame renders. Same gate for begin-play and mid-play swaps. |
Initialization & Swap Gating |
| Where does the PIP widget mount? | A BindWidgetOptional child of UCradlHUDLayout (the always-visible cosmetic-overlay pattern, like PlayerHUD). |
HUD Mount |
| Does this add any replicated state? | No. Everything is local + RF_Transient; the two driver keys are already replicated. |
Replication Audit |
| Does this add any gameplay tags? | One namespace: Pilot.Preview.Block.* (feed-block reasons); only the Death leaf is C++-mirrored. |
Tag Taxonomy |
North Star recap of reuse
| New verb | Reused foundation |
|---|---|
| Cockpit rig | AVisualsRigActor host + driver-key async spawn (ULoadoutVisualsComponent) |
| Pilot rig event surface | BlueprintImplementableEvent pattern on ALoadoutVisualsActor (NotifyPlayerDeathStateChanged) |
| Reaction wiring | UCradlVisualStateComponent delegates + CallWhenReady |
| HUD mount | BindWidgetOptional cosmetic child (UCradlPlayerHUDWidget) |
| Capture / staging | Vendored PocketWorlds (Lyra sample) |
Capture Mechanism
Rule. The pilot is rendered with the vendored PocketWorlds plugin. A UPocketCapture (vendored) drives a USceneCaptureComponent2D with PrimitiveRenderMode = PRM_UseShowOnlyList, allowlisting only the cockpit + pilot rigs, into a UTextureRenderTarget2D (vendored). The HUD PIP widget consumes that render target through a UImage FSlateBrush. No FPreviewScene, no hand-rolled USceneCaptureComponent2D wrapper.
Why. CRADL has no runtime render-target/scene-capture infrastructure — the only precedent is the editor-only UCradlBakeItemIconsCommandlet (CradlBakeItemIconsCommandlet.h), whose own comments warn that its FPreviewScene + BeginRenderingViewFamily path is finicky outside a one-shot headless bake (the GIsClient guard, blank renders). PocketWorlds is the Epic-blessed live pattern, and its dependency surface is Core/CoreUObject/Engine only — no OnlineSubsystem, no Lyra coupling — so vendoring it is far cheaper than the CommonUser port (per project_commonuser_sourcing.md). The two things it gives "for free" are exactly the ones that are painful to retrofit: capture-only texture mip-streaming (UPocketCaptureSubsystem::StreamThisFrame, vendored) so the pilot doesn't render blurry, and full lighting/post isolation via a separate streamed sublevel (see Off-Screen Staging). Per feedback_check_5_4_best_practice.md, prefer the Epic-blessed pattern over a hand-rolled design for foundational rendering.
Implementation surface:
- Plugin (vendored, new to CRADL): Plugins/PocketWorlds/ — copied verbatim from LyraStarterGame54/Plugins/PocketWorlds/. Add to CRADL.uproject plugin list and to the CRADL module Build.cs PublicDependencyModuleNames.
- Key vendored types: UPocketCapture, UPocketCaptureSubsystem, UPocketLevel, UPocketLevelInstance, UPocketLevelSubsystem (all under Plugins/PocketWorlds/Source/).
- Owner: ULoadoutPreviewSubsystem (new) holds the UPocketCapture and the UTextureRenderTarget2D.
Camera (capture view) — cockpit-driven. The vendored CaptureScene reads the view (transform + FOV + post-process, live each frame) off the capture target actor's first UCameraComponent — orthogonal to the allowlist (the allowlist is the rendered set; the target is only the view). The capture target is therefore resolved from the cockpit rig: Cradl::Visuals::ResolvePreviewCamera (PreviewCameraProvider.h, mirroring IVisualsAnchorProvider) returns the cockpit's IPreviewCameraProvider camera, else a UCameraComponent found on the rig BP, else null → the subsystem's owned default framing camera (the pilot-only / no-cockpit fallback). ULoadoutPreviewSubsystem::RefreshCaptureView re-points the target on cockpit spawn/clear and once the pipeline builds, so a cockpit BP authors its own interior framing WYSIWYG (a Camera component placed in the viewport) without code changes.
Footguns:
- Don't reach for FPreviewScene (the IconBaker path) for the live PIP. It is a separate world that's heavy to drive per-frame and carries the GIsClient/blank-render caveats documented in UCradlBakeItemIconsCommandlet. Rejected in favor of PocketWorlds — see Why.
- Don't hand-roll a USceneCaptureComponent2D staging actor in the gameplay world. ShowOnlyActors would stop other actors leaking into the capture, but the gameplay map's directional/sky light, height fog, and global post-process volumes would still wash over an off-origin rig. Avoiding that means authoring a separate staging sublevel — i.e. reinventing UPocketLevel. Rejected for that reason.
- PocketWorlds is a Lyra sample plugin, not engine-native. It must be vendor-copied; confirm it compiles against UE 5.4 inside CRADL before any consumer code lands (this is a Phase-0 gate — see Open Questions #1).
- PRM_UseShowOnlyList is the isolation primitive, not the camera position. The capture renders exactly the allowlist regardless of where the component sits; keep the allowlist authoritative (cockpit rig + pilot rig + their attached children) so nothing else can ever appear in the PIP.
- The capture target is the view source, not the rendered set. Re-pointing it at the cockpit (RefreshCaptureView) changes only camera transform/FOV/post-process; the allowlist is passed independently to CaptureAllowlist. The vendored capture reads the first UCameraComponent on the target, so a cockpit BP should host exactly one preview camera (or disambiguate via IPreviewCameraProvider::GetPreviewCamera). On cockpit teardown the target must revert to the owned fallback camera, or it dangles at the destroyed rig (handled in ClearCockpitRig).
Related: Off-Screen Staging, Capture Cadence, STAT_PIPELINE.md "Lyra Alignment", feedback_check_5_4_best_practice.md.
Off-Screen Staging
Rule. The cockpit + pilot rigs are staged inside a UPocketLevelInstance (vendored) — a ULevelStreamingDynamic of an authored UPocketLevel (vendored data asset + content level), streamed in off-origin and marked bClientOnlyVisible. The staging level owns its own lighting and post-process so the pilot's look is independent of whatever gameplay map the player is in. The capture's ShowOnlyActors allowlist is the second isolation layer.
Why. Note the vendored UPocketLevelInstance streams the staging level as a ULevelStreamingDynamic into the same UWorld (World = LocalPlayer->GetWorld()), off-origin — not a hermetically separate world. Isolation is therefore a stack, not one mechanism: (1) off-origin distance kills local point/spot lights (finite attenuation never reaches 10 km) and makes height fog negligible at altitude; (2) the vendored UPocketCapture::CaptureScene disables sky lighting, volumetric fog, post-process materials, color grading, bloom, light shafts, AO, etc. via show flags, so the gameplay map's ambient/PP doesn't bleed; (3) the staging UPocketLevel supplies its own key light for a consistent look; (4) PRM_UseShowOnlyList is the geometry isolation — only allowlisted actors render, so no world meshes leak. bClientOnlyVisible keeps the staged content off the network entirely, consistent with the local-only mandate.
The one residual bleed vector is a directional light (see Footguns) — Lyra never hits it because its PocketWorlds usage is the front-end, with no competing gameplay sun; CRADL's during-gameplay PIP does. It is closed by a lighting-channel convention, a content/Phase-4 concern, not a Phase-3 code one.
Implementation surface:
- Content (deferred authoring task): a UPocketLevel data asset + its template ULevel (the staging room with lights/post), under /Game/ per THEME.md generic naming (e.g. /Game/Visuals/Preview/PL_LoadoutPreview).
- ULoadoutPreviewSubsystem (new) resolves the local ULocalPlayer, calls UPocketLevelSubsystem::GetOrCreatePocketLevelFor (vendored) to stream the instance at an off-origin spawn point, and spawns both rigs into that instance's world.
Footguns:
- Off-origin precision. Stage at a large-but-not-extreme offset (the PocketWorlds default spawn-point convention) to avoid float-precision wobble in skeletal animation. Don't push to the world-bounds extreme.
- One pocket instance per local player, not per pawn. In listen-server P2P the host is also a local player and builds its own instance for its own pilot; remote proxies and dedicated servers build none (no local player → subsystem no-ops). This is the local-only guarantee enforced structurally.
- The staging level lights the subject, not the gameplay world. Authoring the cockpit's mood belongs in the UPocketLevel; never add lights to the gameplay map to fix the PIP.
- A gameplay directional light bleeds onto the staged subject — directional lights are infinite (not distance-attenuated) and are not among the show flags the vendored capture disables, so the same-world architecture leaks the sun onto the off-origin pilot. Close it with lighting channels: put the staged rigs' mesh components + the staging level's key light on a dedicated channel, and keep the gameplay sun on the default channel only. This is WYSIWYG content authoring (the rig BP sets its components' channels) plus the staging-level light; the spawn code does not force channels. A Phase-4 / content-pass task, not Phase 3.
Related: Capture Mechanism, Ownership & Lifecycle, Replication Audit.
Scene Composition
Rule. The PIP scene is composed of two locally-spawned rigs, each driven by an already-replicated key:
- Cockpit rig —
ALoadoutPreviewVisualsActor(new), anAVisualsRigActorsubclass, resolved from a newULoadoutDefinition::PreviewVisualsActor(new)TSoftClassPtrand keyed offULoadoutComponent::ActiveLoadoutId. This is a preview-only loadout rig, distinct from the in-world ship rig (ULoadoutDefinition::VisualsActor/ALoadoutVisualsActor, which attaches to the gameplay pawn). - Pilot rig — the existing
AModifierVisualsActor(ModifierVisualsActor.h), resolved fromULoadoutModifierDefinition::VisualsActorand keyed off the activeModifier.Pilotassignment. The pilot's skeletal mesh + anim BP are authored inside this rig's BP subclass.
Both rigs are spawned into the pocket level world (not the gameplay world), RF_Transient, and added to the capture's ShowOnlyActors.
Why. Cockpit-follows-ship and pilot-follows-pilot is the correct identity split (per THEME.md, "ship" = loadout, "pilot" = loadout-modifier). Keying off ActiveLoadoutId (LoadoutComponent.h, DOREPLIFETIME + OnLoadoutChanged) and the modifier assignment (LoadoutModifierComponent.h, ActiveAssignments + OnAssignmentsChanged) reuses the exact driver-key async-spawn pattern proven by ULoadoutVisualsComponent and UEnemyVisualsComponent — the wire carries only the small replicated key; each client resolves the TSoftClassPtr and rebuilds locally. A separate PreviewVisualsActor field (rather than reusing the in-world VisualsActor) lets the cockpit be authored for a tight interior framing without compromising the gameplay-world ship silhouette.
Implementation surface:
- New field: ULoadoutDefinition::PreviewVisualsActor (new) TSoftObjectPtr/TSoftClassPtr<ALoadoutPreviewVisualsActor> — LoadoutDefinition.h. Null = no cockpit (pilot renders against the bare staging backdrop).
- New rig: ALoadoutPreviewVisualsActor (new) — Source/CRADL/Loadout/LoadoutPreviewVisualsActor.{h,cpp} (new), : public AVisualsRigActor.
- Existing rig: AModifierVisualsActor — gains the event surface in Event-Reaction Surface.
- Async load: reuse UAssetManager::GetStreamableManager().RequestAsyncLoad with in-flight cancel on rapid key change, exactly as LoadoutVisualsComponent.cpp does (guard-in-callback against superseded keys).
Footguns:
- Don't conflate PreviewVisualsActor (cockpit, PIP) with VisualsActor (ship, gameplay world). They are two different rigs with different framing needs; the in-world ship rig stays attached to the pawn via Cradl::Visuals::ResolveAnchor (VisualsAnchorProvider.h) and is unaffected by this contract.
- Pilot rig must carry its own skeletal mesh + anim BP in the BP subclass; the C++ AModifierVisualsActor stays a generic rig host (no skeletal-mesh member in C++ — content authors it under the SceneComponent root, per the AVisualsRigActor WYSIWYG convention).
- Rapid swaps strand in-flight loads unless every async callback guards against the current key — reuse the ActiveLoadoutIdLocal / ModifierDefId-mismatch guard pattern from the existing components, don't invent a new one.
- Cockpit absent / pilot absent are both valid. Null PreviewVisualsActor → pilot-only against backdrop; no Modifier.Pilot assigned ("the player is the pilot," per LoadoutModifierSwapAbility.h) → cockpit-only. The subsystem composes whichever rigs resolve.
Related: Ownership & Lifecycle, STAT_PIPELINE.md "Pilot (loadout modifier)".
Ownership & Lifecycle
Rule. A new ULoadoutPreviewSubsystem (new) : public UWorldSubsystem owns the entire PIP: the UPocketLevelInstance, the UPocketCapture + UTextureRenderTarget2D, and the local spawn/teardown of both rigs. It is built only for the local player (no local ULocalPlayer → no-op). It watches the local player's ULoadoutComponent::OnLoadoutChanged (cockpit) and ULoadoutModifierComponent::OnAssignmentsChanged (pilot), resolving both components off the local ACradlPlayerState. The existing pawn-side ULoadoutModifierVisualsComponent (LoadoutModifierVisualsComponent.h) origin-spawn is retired; its responsibility moves into the subsystem (which spawns into the pocket world instead of the gameplay world).
Why. A UWorldSubsystem mirrors PocketWorlds' own UPocketCaptureSubsystem / UPocketLevelSubsystem model, is local-by-construction (subsystems exist per client; gate on local player for local-only), is reachable from the HUD widget for the render target, and is torn down/rebuilt cleanly on map travel. The pawn-side component is the wrong home: it lives on the replicated gameplay pawn in the gameplay world, can't own a local pocket world, and would run on remote proxies where no PIP should exist. Its header already declares itself a placeholder "for a future picture-in-picture host to reparent into its capture rig" (LoadoutModifierVisualsComponent.h) — absorbing it is the intended endpoint, not a regression.
Implementation surface:
- ULoadoutPreviewSubsystem (new) — Source/CRADL/Visuals/LoadoutPreviewSubsystem.{h,cpp} (new). Holds: TObjectPtr<UPocketCapture>, TObjectPtr<UTextureRenderTarget2D>, weak refs to the two spawned rigs, FStreamableHandle for in-flight loads, delegate handles for the two driver keys + the VSC bindings.
- Build trigger: gameplay-start gate (see Gameplay-Start Hook).
- Teardown: Deinitialize() destroys the pocket instance, capture, RT, and both rigs; subsystem holds no replicated state, so PIE re-entry rebuilds from scratch.
- Retired: remove the spawn/GetSpawnedActor path from ULoadoutModifierVisualsComponent. Per feedback_no_clean_up_later.md, every reference to it (the ACradlCharacter subobject construction, the GetSpawnedActor comment chain) is in-scope for the same change — don't leave a dangling pawn component.
Footguns:
- Late-bind on remote/host startup. The local ACradlPlayerState and its loadout/modifier components arrive after world begin on clients. Use the same retry pattern the existing visuals components use (ACradlCharacter::OnPlayerStateReplicated re-probe), and seed current state on bind (probe GetActiveLoadoutId() / current assignments) rather than waiting for the next change broadcast.
- Subsystem runs on the server world too. Gate the entire build path on "this world has a local player controller." On a dedicated server this is empty; on a listen-server host it builds for the host's own pilot only.
- Don't replicate the subsystem or its rigs. All state is local + RF_Transient (see Replication Audit).
- One owner for spawn. After this change the only code that spawns preview rigs is the subsystem. Do not leave a second spawn path on the pawn component (the double-spawn footgun).
Related: Scene Composition, Gameplay-Start Hook, Replication Audit, feedback_no_clean_up_later.md.
Event-Reaction Surface
Rule. The subsystem binds the local pawn's UCradlVisualStateComponent and forwards a curated set of signals to BlueprintImplementableEvent hooks on the pilot rig (AModifierVisualsActor, new events) — and optionally the cockpit rig. The anim BP and BP rig logic consume these BIE events; C++ forwards, BP reacts (no Event Graph logic beyond cosmetic anim per CLAUDE.md). The curated set:
| Reaction | VSC source delegate | Pilot-rig BIE (new) |
|---|---|---|
| XP gained | OnXPGained(SkillTag, Delta, NewXP) |
NotifyXPGained |
| Level up | OnLevelUp(SkillTag, NewLevel) |
NotifyLevelUp |
| Damage taken | OnAttributeChanged(Health, New, Old) (Health auto-watched) |
NotifyHealthChanged |
| In combat | OnTagStateChanged(Status.InCombat, bActive) |
NotifyCombatStateChanged |
| Pilot rig activated | pilot-rig spawn, gated on OnVisualStateReady |
NotifyActivated |
Why. UCradlVisualStateComponent (CradlVisualStateComponent.h) is the project's read-only adapter that already re-broadcasts GAS/skill state as BP-friendly delegates, and is the canonical source the HUD's UCradlPlayerHUDWidget binds for exactly these signals. Reusing it (rather than re-subscribing to GAS/USkillsComponent directly) keeps one event surface and inherits the solved late-bind story. Forwarding to BIE on the rig mirrors ALoadoutVisualsActor::NotifyPlayerDeathStateChanged (LoadoutVisualsActor.h) — the established "component forwards external event → rig BIE → BP authors the cosmetic" pattern.
Implementation surface:
- New BIE events on AModifierVisualsActor — ModifierVisualsActor.h (declared unconditionally; cosmetic-only). Mirror the NotifyPlayerDeathStateChanged shape: idempotent on payload, default branch = no-op.
- Binding lives on ULoadoutPreviewSubsystem (new): locate the local pawn's VSC via GetPawn()->FindComponentByClass<UCradlVisualStateComponent>(), subscribe, re-bind on possession churn (OnPossessedPawnChanged), unbind in Deinitialize. This is the UCradlPlayerHUDWidget bind flow, lifted to the subsystem.
- Combat reuse: the same Status.InCombat signal the HUD brokers via UCradlHUDLayout::OnInCombatChanged (CradlHUDLayout.h); the subsystem may bind either the HUD broker or VSC's OnTagStateChanged directly — prefer VSC to avoid coupling the subsystem to the widget.
Footguns:
- OnXPGained / OnLevelUp are owner-only (CradlVisualStateComponent.h — fire on authority + owning client only). That is exactly right for a local-only PIP; do not "fix" their absence on remote proxies (there is no remote PIP).
- OnTagStateChanged is a firehose. It broadcasts every tag under the watched roots (Action/State/Status). Filter for Status.InCombat in the handler — don't forward raw.
- Bind after ready, not in raw begin. Use CallWhenReady (which fires immediately if already ready, else on init) to avoid the bind-before-ASC race — the same mitigation cited for feedback_umg_tooltip_delegate_init_timing.md.
- Per feedback_presentation_struct_resolve_tags.md, if any BIE payload carries a raw FGameplayTag (e.g. SkillTag on NotifyXPGained), pair it with a pre-resolved display name/icon if the rig needs to label it — but for pure anim reactions the tag alone is fine (no UI label lookup in BP). Resolve only if the rig surfaces text.
Related: Gameplay-Start Hook, CradlVisualStateComponent.h, feedback_umg_tooltip_delegate_init_timing.md.
External Block / Feed-Offline Signal
Rule. Beyond the VSC reaction surface, the subsystem owns a generic visual-block state — a tag-keyed source-set (ActiveBlockReasons, an FGameplayTagContainer). The PIP "feed" is blocked while the set is non-empty. SetVisualBlocked(FGameplayTag Reason, bool bBlocked) (new, BlueprintCallable) is the single entry point every cause routes through; it broadcasts FOnPreviewBlockChanged(bool bBlocked, FGameplayTagContainer Reasons) (new). UCradlLoadoutPreviewWidget re-broadcasts it as the OnVisualBlockStateChanged BIE (the WBP draws a "feed offline" overlay); the pilot rig AModifierVisualsActor receives NotifyVisualBlockChanged (new) directly (the BP authors an eject animation / freeze / no-op). C++ forwards the boolean + reasons; BP authors the response — whether "block" means an offline overlay or a pilot eject anim is a content decision, not a code one.
The only v1 cause is the local player's death: the subsystem binds the local ACradlPlayerState::OnDeathStateChanged (CradlPlayerState.h) and holds Pilot.Preview.Block.Death for any non-Alive phase, releasing it on Alive.
Why. Two orthogonal axes, not one. Readiness (see Initialization & Swap Gating) is the system-driven "scene is mid-build, hold a placeholder" signal — transient and self-clearing. Block is the externally-driven "suppress the feed" signal — persistent until its cause clears. Folding death into the readiness enum would mis-read a respawn as "loading"; they stay separate booleans. A tag-keyed source-set (rather than a bool or a single-reason enum) lets overlapping causes ref-count correctly: death releasing the block never lifts a block another cause still holds, and a new cause is a new .ini leaf with no code change. Death is sourced PS-direct (not via VSC) because death is a PlayerState lifecycle signal, not a GAS/skill stat — and the in-world ship rig already binds PS death this way (ULoadoutVisualsComponent::HandleDeathStateChanged → ALoadoutVisualsActor::NotifyPlayerDeathStateChanged, LoadoutVisualsComponent.h), so this mirrors a proven precedent and avoids widening VSC for one local consumer. PlayerState outlives pawn possession, so the binding is more stable than the VSC reactions: bound once in BindDriverKeys, never re-pointed on churn.
Implementation surface:
- ULoadoutPreviewSubsystem (new surface) — FGameplayTagContainer ActiveBlockReasons, FOnPreviewBlockChanged OnPreviewBlockChanged (BlueprintAssignable), SetVisualBlocked / IsVisualBlocked / GetActiveBlockReasons, HandleDeathStateChanged, BroadcastVisualBlock. The death delegate is bound + seeded in BindDriverKeys alongside the driver keys and unbound in Deinitialize.
- AModifierVisualsActor::NotifyVisualBlockChanged(bool, FGameplayTagContainer) (new BIE) — ModifierVisualsActor.h.
- UCradlLoadoutPreviewWidget (new) — OnVisualBlockStateChanged BIE + IsVisualBlocked pure getter + HandleVisualBlockChanged subscriber, mirroring the readiness re-broadcast (CradlLoadoutPreviewWidget.h).
- Tag: Pilot.Preview.Block + Pilot.Preview.Block.Death (.ini; only the Death leaf is mirrored in CradlGameplayTags).
- Cheat: Debug_TogglePilotPreviewBlock exec on CradlPlayerController (#if !UE_BUILD_SHIPPING body only).
Footguns:
- Block is a signal, not a capture-pause. Do not route it into SetPreviewActive / the capture pause — an eject anim needs the capture live. Keep it orthogonal to the visibility/cadence gate, exactly as readiness keeps the RT live under its placeholder.
- Seed on bind and on rig spawn. A pilot constructed (or swapped in) while the feed is already blocked must show offline immediately; BroadcastVisualBlock's fan-out only reaches rigs that existed when the block last toggled, so SpawnPilotRig re-seeds a fresh rig.
- Membership-change guard. SetVisualBlocked no-ops (no broadcast) when the reason's set membership is unchanged (HasTagExact), so a redundant seed or a cause re-asserting costs nothing.
- Cockpit excluded by default, per Open Questions #5 — only the pilot rig + widget receive the block. Wire the cockpit only if a content need surfaces.
Related: Event-Reaction Surface, Initialization & Swap Gating, Replication Audit, Tag Taxonomy.
Gameplay-Start Hook
Rule. The subsystem builds/populates the PIP when the local pawn's UCradlVisualStateComponent reports ready — via OnVisualStateReady or CallWhenReady (CradlVisualStateComponent.h). The pilot rig's NotifyActivated (new) BIE fires when the rig finishes spawning, which is itself gated on this readiness signal (no rig spawns before the gate opens).
Why. There is no single "all systems ready" broadcast in the codebase; the closest reliable, local, fire-once-after-binding signal is OnVisualStateReady (fires once both ASC and PlayerState are bound, with an idempotent retry on OnPlayerStateReplicated). It is the same readiness contract the HUD relies on, so the PIP comes alive in lockstep with the rest of the HUD rather than racing it. The loadout/modifier components are resolved off the same ACradlPlayerState, so VSC-ready is a safe lower bound for "the driver keys are reachable."
Implementation surface:
- ULoadoutPreviewSubsystem::Initialize registers a CallWhenReady against the local VSC (replaying on possession churn); the ready callback builds the pocket instance, resolves both driver keys, and kicks the async rig loads.
- NotifyActivated (new) BIE on the pilot rig — fired each time a pilot rig finishes spawning post-ready (begin-play and every mid-play loadout/pilot swap), so the BP can author an entrance / intro reaction. A per-rig lifecycle hook, not a once-per-session signal — named for the per-spawn behavior.
Footguns:
- OnVisualStateReady is not "HUD is bound." It is VSC-local. If a future need requires the HUD's PIP widget to exist first (e.g. to size the render target to the widget), gate the widget-side seed on the widget's own construct, and keep the subsystem-side build on VSC-ready — don't couple the two readiness clocks.
- Per PLAYFLOW_SYSTEM.md, the welcome toast and name stamp happen in ACradlPlayerState::BeginPlay, which may precede HUD bind on remote clients. The PIP must not assume the HUD is up at VSC-ready; the widget binds the subsystem's render target lazily when it constructs.
- Idempotent build. CallWhenReady + possession churn can re-enter; building the pocket instance must no-op if one already exists for this local player.
Related: PLAYFLOW_SYSTEM.md "Identity Carry-Through", Event-Reaction Surface.
Capture Cadence
Rule. While the PIP widget is visible, the capture runs every frame (UPocketCapture configured bCaptureEveryFrame = true, deviating from PocketWorlds' thumbnail default) so the pilot's idle/breathing/reaction animation plays live. When the PIP is hidden (HUD page covers it, PIP toggled off, or HUD torn down), the capture stops and the rig tick is paused — capturing GPU cost only while on-screen.
Why. The pilot is a skeletal mesh + anim BP that reacts continuously; an on-demand thumbnail capture would freeze it between events. Live-while-visible is the chosen feel (animated, alive). The cost is bounded by (a) a small render target (e.g. 256–512 px) and (b) hard-pausing when hidden, so an off-screen PIP is free.
Implementation surface:
- ULoadoutPreviewSubsystem (new) toggles bCaptureEveryFrame and the rigs' tick/visibility in response to the PIP widget's visibility (widget pushes a SetPreviewActive(bool) call, or the subsystem observes HUD page state).
- Render-target size: a config value on a UDeveloperSettings subsystem settings class (mirror UCradlCombatSettings / UCradlLevelSettings shape) — (new) UCradlPilotPreviewSettings if a settings home is wanted, else a constant on the subsystem. Default small; revisit per target hardware.
Footguns:
- bCaptureEveryFrame=true is a deliberate deviation from PocketWorlds' default (false, thumbnail-oriented). Document it at the call site so a future PocketWorlds re-sync doesn't silently revert it.
- Pause means pause both. Stopping capture but leaving the rig ticking wastes anim eval; stopping the rig but leaving capture on shows a frozen frame at cost. Gate both on the same visibility signal.
- Texture streaming must stay pinned while visible. UPocketCaptureSubsystem::StreamThisFrame (vendored) needs to run on the captured components each active frame or the pilot textures drop to low mips — keep the vendored subsystem's ticker path intact.
Related: Capture Mechanism, HUD Mount.
Initialization & Swap Gating
Rule. ULoadoutPreviewSubsystem (new) exposes a readiness signal that gates the moment the PIP scene becomes visible, so a rebuild — initial begin-play build or a mid-play loadout/pilot swap — never shows pop-in (blank frames, a half-spawned rig, or a skeletal mesh mid-pose-init). The subsystem enters not-ready the instant any driver key changes (cockpit or pilot), and returns to ready only after every in-flight async load has resolved, both rigs have spawned, and the capture has produced at least one frame of the new scene. It broadcasts FOnPreviewReadyChanged(bool bReady) (new); UCradlLoadoutPreviewWidget (new) re-broadcasts it as a BlueprintImplementableEvent so the WBP authors its own "initializing, please wait" placeholder over the render target. The same signal serves both entry points — the begin-play route and runtime loadout/modifier selection.
Why. The swap window is inherently racy: the old rig is destroyed while the new soft classes are async-loaded and spawned over several frames, during which the render target holds a stale or blank image. Surfacing one readiness boolean — rather than letting the WBP infer the moment from animation or timers — lets the placeholder cover the entire transition deterministically. Driving it from the subsystem (the only owner that knows the async/spawn/first-capture state) keeps the widget a pure view (CLAUDE.md: BP is a data container, C++ owns logic). Per feedback_loading_feel_min_hold.md, a too-fast swap that flashes the placeholder for a single frame reads as a glitch; an optional minimum placeholder hold smooths it.
Implementation surface:
- ULoadoutPreviewSubsystem (new): FOnPreviewReadyChanged OnPreviewReadyChanged multicast + bool IsPreviewReady() const. Transition to not-ready inside the driver-key change handlers (before tearing down the old rig); transition to ready on the first capture after all pending loads/spawns settle (e.g. a one-tick-after-spawn confirmation so ≥1 frame of the new scene has rendered).
- UCradlLoadoutPreviewWidget (new): subscribe to OnPreviewReadyChanged; expose UFUNCTION(BlueprintImplementableEvent) void OnPreviewReadyStateChanged(bool bReady) for the WBP placeholder, plus UFUNCTION(BlueprintPure) bool IsPreviewReady() const to seed initial state on construct. Seed not-ready on construct when the subsystem hasn't built yet (the begin-play route reaches the widget before the first scene exists).
- Optional minimum placeholder hold: a config value (the settings home in Open Questions #4) so the placeholder, once raised, stays up for at least N seconds.
Footguns:
- Ready must fire on first capture of the new scene, not on rig spawn. Signalling the instant the rig actor spawns reveals a frame where the skeletal mesh hasn't evaluated its first pose (T-pose / origin flash). Wait for the post-spawn capture frame.
- Enter not-ready before destroying the old rig, so the placeholder is already up when the scene goes blank — don't tear down then signal.
- One gate for both entry points. Don't special-case the initial build with a separate path; OnVisualStateReady (see Gameplay-Start Hook) drives not-ready→ready through this same signal, exactly as a mid-play swap does.
- Per feedback_loading_feel_min_hold.md, default the minimum placeholder hold to a non-zero value so a one-frame swap still reads as intentional rather than a flicker.
- The placeholder is the WBP's, not C++'s. The subsystem and widget emit only the boolean; the "please wait" visual (spinner, fade, frozen-last-frame) is BP-authored per CLAUDE.md.
Related: Capture Cadence, HUD Mount, Gameplay-Start Hook, Ownership & Lifecycle, feedback_loading_feel_min_hold.md.
HUD Mount
Rule. The PIP is a UCradlLoadoutPreviewWidget (new) : public UCradlUserWidget, mounted as a BindWidgetOptional child of UCradlHUDLayout (CradlHUDLayout.h) — the always-visible cosmetic-overlay pattern used by PlayerHUD and MessageLog, not a CommonUI stack push. It binds the ULoadoutPreviewSubsystem's render target to a UImage brush and forwards its visibility to the subsystem's capture pause/resume.
Why. The pilot PIP is always-visible HUD furniture with set-once binding and no activation/back-stack semantics — exactly the BindWidgetOptional direct-child shape (UCradlPlayerHUDWidget), not the transient/modal stack-push shape (UI.Layer.*). Subclassing UCradlUserWidget inherits the project widget helpers (tooltip attach, context-menu dismissal) and keeps it off the activatable-page machinery.
Implementation surface:
- UCradlLoadoutPreviewWidget (new) — Source/CRADL/UI/CradlLoadoutPreviewWidget.{h,cpp} (new). A UImage BindWidget child shows the RT; on construct it resolves the local ULoadoutPreviewSubsystem and sets the brush; on visibility change it calls the subsystem's SetPreviewActive.
- Readiness API: subscribes to ULoadoutPreviewSubsystem::OnPreviewReadyChanged and exposes the OnPreviewReadyStateChanged(bool) BIE + IsPreviewReady() pure getter so the WBP can show a "please wait" placeholder during a build/swap (see Initialization & Swap Gating).
- New optional member on UCradlHUDLayout: UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<UCradlLoadoutPreviewWidget> LoadoutPreview; — CradlHUDLayout.h. A minimal HUD WBP may omit it.
Footguns:
- Widget-facing UPROPERTYs use EditAnywhere, not EditDefaultsOnly (per feedback_umg_widget_uproperty_edit_specifier.md) so a placed instance exposes them in the Designer.
- Avoid Slot as a local in this UUserWidget subclass — it shadows UWidget::Slot (per feedback_slot_shadows_uwidget.md); use PreviewImage/Src.
- BindWidgetOptional, not BindWidget. Cosmetic overlays must be omittable so a stripped HUD still compiles/binds (the PlayerHUD/MessageLog precedent).
- The widget is a view, not the owner. The render target and capture live on the subsystem; the widget only displays and toggles visibility. Don't move capture logic into UMG (CLAUDE.md: BP is a data container).
Related: Capture Cadence, UCradlPlayerHUDWidget, feedback_umg_widget_uproperty_edit_specifier.md, feedback_slot_shadows_uwidget.md.
Replication Audit
Per feedback_p2p_replication_audit.md, every field gets a deliberate answer. This system introduces zero replicated state.
| State | Where | Replication answer |
|---|---|---|
UPocketLevelInstance / streamed staging level |
subsystem | Local-only. bClientOnlyVisible (vendored). Never networked. |
UPocketCapture, UTextureRenderTarget2D |
subsystem | Local-only, transient UObjects. Cosmetic — replicating them is forbidden (CLAUDE.md). |
ALoadoutPreviewVisualsActor (cockpit rig) (new) |
pocket world | Local-only, RF_Transient, bReplicates=false. Each client rebuilds from ActiveLoadoutId. |
AModifierVisualsActor (pilot rig) |
pocket world | Local-only, RF_Transient, bReplicates=false. Each client rebuilds from the modifier assignment. |
ULoadoutPreviewSubsystem state (new) |
subsystem | Local-only. Subsystems aren't replicated; built only where a local player exists. |
| New BIE reaction events on rigs (new) | rigs | Local-only cosmetic calls; never replicated (CLAUDE.md: don't replicate cosmetic state). |
Visual-block source-set + OnPreviewBlockChanged (new) |
subsystem | Local-only. Derived from the already-replicated ACradlPlayerState::DeathState (consumed, not added); the block set + broadcast are cosmetic, never networked. |
ULoadoutDefinition::PreviewVisualsActor (new) |
DataAsset | Editor data, not runtime state — soft class reference, replicated by nobody. |
Driver key: ULoadoutComponent::ActiveLoadoutId |
PlayerState | Already replicated (DOREPLIFETIME) — consumed, not added. |
Driver key: ULoadoutModifierComponent::ActiveAssignments |
PlayerState | Already replicated — consumed, not added. |
| VSC reaction signals | local pawn VSC | Derived from already-replicated GAS/skill state; owner-only paths are correct for local-only consumption. |
Net: the PIP rides entirely on two pre-existing replicated keys and adds nothing to the wire.
Related: Off-Screen Staging, Ownership & Lifecycle, feedback_p2p_replication_audit.md.
Tag Taxonomy
This system introduces one new namespace — the feed-block reasons — and otherwise consumes, by name:
Pilot.Preview.Block+Pilot.Preview.Block.Death(new) —.ini; the visual-block source-set keys (see External Block / Feed-Offline). Perfeedback_gameplay_tag_decl_minimal.mdthe.iniholds the authoritative list and only theDeathleaf is C++-mirrored (CradlTags::Pilot_Preview_Block_Death), becauseHandleDeathStateChangedreferences it by name; future causes add an.inileaf and mint a C++ symbol only if code names it.Modifier.Pilot—.ini+ C++ (CradlTags::Modifier_Pilot, CradlGameplayTags.h) — the pilot slot the rig is keyed off (via the existingPilotSlotTagresolution).Status.InCombat—.ini+ C++ — the in-combat reaction signal, read through VSC'sOnTagStateChanged.
Spawn stays delegate-driven (loadout/modifier change broadcasts) and stat reactions stay VSC-driven; the only tag the system mints is the feed-block reason, which is a genuine new piece of named state (a cause keying the source-set), not a spawn/reaction discriminator.
Related: feedback_gameplay_tag_decl_minimal.md, feedback_native_tag_no_cross_module_export.md (validator side).
Validators
UCradlLoadoutDefinitionValidator(Source/CRADLEditor/Validators/CradlLoadoutDefinitionValidator.h) — must be updated in lockstep (per CLAUDE.md) whenULoadoutDefinition::PreviewVisualsActor(new) lands: validate the soft class is resolvable and derives fromALoadoutPreviewVisualsActor. Resolve any tag checks viaRequestGameplayTagstrings, notCradlTags::X(perfeedback_native_tag_no_cross_module_export.md).UCradlLoadoutModifierDefinitionValidator(Source/CRADLEditor/Validators/CradlLoadoutModifierDefinitionValidator.h) — added (resolves Open Questions #2). ValidatesDisplayNamenon-empty,Iconset + resolves,SlotTagvalid + underModifier.*(root resolved viaRequestGameplayTagstring, notCradlTags::X, perfeedback_native_tag_no_cross_module_export.md), andVisualsActorresolvable when set. Now thatVisualsActoris load-bearing for the pilot rig, this gap is closed.
Forward Code References
Stable anchors future PRs land at:
Plugins/PocketWorlds/(new, vendored) — capture + staging plugin.Source/CRADL/Visuals/LoadoutPreviewSubsystem.{h,cpp}(new) — the PIP host (UWorldSubsystem).Source/CRADL/Visuals/LoadoutPreviewCapture.{h,cpp}(new) — concreteUPocketCapture(the vendored type isAbstract); adds an explicit-allowlist diffuse capture soShowOnlyActorsstays decoupled from the capture target's attachment hierarchy.Source/CRADL/Visuals/PreviewCameraProvider.{h,cpp}(new) —IPreviewCameraProvider+Cradl::Visuals::ResolvePreviewCamera; lets the cockpit rig dictate the capture view (mirrorsIVisualsAnchorProvider).Source/CRADL/Loadout/LoadoutPreviewVisualsActor.{h,cpp}(new) — the cockpit rig (AVisualsRigActorsubclass).Source/CRADL/Loadout/ModifierVisualsActor.{h,cpp}(existing) — add reaction BIE events.Source/CRADL/Loadout/LoadoutDefinition.h(existing) — addPreviewVisualsActorfield.Source/CRADL/Loadout/LoadoutModifierVisualsComponent.{h,cpp}(existing) — retire the origin-spawn path.Source/CRADL/UI/CradlLoadoutPreviewWidget.{h,cpp}(new) — the HUD PIP widget.Source/CRADL/UI/CradlHUDLayout.h(existing) — addLoadoutPreviewBindWidgetOptionalmember.Source/CRADLEditor/Validators/CradlLoadoutDefinitionValidator.{h,cpp}(existing) — validate the new field.- Content (deferred authoring):
/Game/Visuals/Preview/PL_LoadoutPreview(UPocketLevel+ stagingULevel), cockpitALoadoutPreviewVisualsActorBP per loadout, pilotAModifierVisualsActorBP per modifier.
Open Questions
-
PocketWorlds clean port to CRADL / UE 5.4. The plugin is engine-deps-only (
Core/CoreUObject/Engine),EnabledByDefault:false, ~1000 LOC + 3 material assets — but it ships with the Lyra sample, not the base engine. Verifying it compiles and theM_PocketCaptureMaskedcontent imports cleanly inside CRADL is a Phase-0 gate before any consumer code is built. (Load-bearing on the whole system.) -
ULoadoutModifierDefinitionvalidator. RESOLVED —UCradlLoadoutModifierDefinitionValidatoradded in this work (validatesDisplayName/Icon/SlotTag/VisualsActor; auto-registers viaUEditorValidatorBase). See Validators. -
PIP interactivity (v1 scope). This contract scopes the PIP to display + reaction only. Click-to-rotate the pilot, click-to-open the loadout-modifier swap panel (LoadoutModifierSwapAbility.h), or hover tooltips are out of scope for v1 — flag for a follow-up if wanted, since input on a render-target-backed widget needs its own design.
-
Render-target sizing, cadence cap & placeholder hold, and a settings home. Whether RT size / cadence cap / minimum placeholder hold (see Initialization & Swap Gating) live on a new
UCradlPilotPreviewSettings(UDeveloperSettings) or as subsystem constants. Defaulting small; decide when tuning on target hardware. -
Cockpit reaction surface. Whether the cockpit rig (not just the pilot) also receives reaction BIE events (e.g. combat-state lighting shift). Default: pilot-only; cockpit is a static backdrop unless a content need surfaces.