0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI PILOT_SYSTEM
UTC 00:00:00
◀ RETURN
PILOT_SYSTEM.md 6005 words ~27 min read Updated 2026-07-03

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) → UTextureRenderTarget2DUImage 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:

  1. Cockpit rigALoadoutPreviewVisualsActor (new), an AVisualsRigActor subclass, resolved from a new ULoadoutDefinition::PreviewVisualsActor (new) TSoftClassPtr and keyed off ULoadoutComponent::ActiveLoadoutId. This is a preview-only loadout rig, distinct from the in-world ship rig (ULoadoutDefinition::VisualsActor / ALoadoutVisualsActor, which attaches to the gameplay pawn).
  2. Pilot rig — the existing AModifierVisualsActor (ModifierVisualsActor.h), resolved from ULoadoutModifierDefinition::VisualsActor and keyed off the active Modifier.Pilot assignment. 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 AModifierVisualsActorModifierVisualsActor.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::HandleDeathStateChangedALoadoutVisualsActor::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). Per feedback_gameplay_tag_decl_minimal.md the .ini holds the authoritative list and only the Death leaf is C++-mirrored (CradlTags::Pilot_Preview_Block_Death), because HandleDeathStateChanged references it by name; future causes add an .ini leaf 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 existing PilotSlotTag resolution).
  • Status.InCombat.ini + C++ — the in-combat reaction signal, read through VSC's OnTagStateChanged.

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) when ULoadoutDefinition::PreviewVisualsActor (new) lands: validate the soft class is resolvable and derives from ALoadoutPreviewVisualsActor. Resolve any tag checks via RequestGameplayTag strings, not CradlTags::X (per feedback_native_tag_no_cross_module_export.md).
  • UCradlLoadoutModifierDefinitionValidator (Source/CRADLEditor/Validators/CradlLoadoutModifierDefinitionValidator.h) — added (resolves Open Questions #2). Validates DisplayName non-empty, Icon set + resolves, SlotTag valid + under Modifier.* (root resolved via RequestGameplayTag string, not CradlTags::X, per feedback_native_tag_no_cross_module_export.md), and VisualsActor resolvable when set. Now that VisualsActor is 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) — concrete UPocketCapture (the vendored type is Abstract); adds an explicit-allowlist diffuse capture so ShowOnlyActors stays 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 (mirrors IVisualsAnchorProvider).
  • Source/CRADL/Loadout/LoadoutPreviewVisualsActor.{h,cpp} (new) — the cockpit rig (AVisualsRigActor subclass).
  • Source/CRADL/Loadout/ModifierVisualsActor.{h,cpp} (existing) — add reaction BIE events.
  • Source/CRADL/Loadout/LoadoutDefinition.h (existing) — add PreviewVisualsActor field.
  • 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) — add LoadoutPreview BindWidgetOptional member.
  • Source/CRADLEditor/Validators/CradlLoadoutDefinitionValidator.{h,cpp} (existing) — validate the new field.
  • Content (deferred authoring): /Game/Visuals/Preview/PL_LoadoutPreview (UPocketLevel + staging ULevel), cockpit ALoadoutPreviewVisualsActor BP per loadout, pilot AModifierVisualsActor BP per modifier.

Open Questions

  1. 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 the M_PocketCaptureMasked content imports cleanly inside CRADL is a Phase-0 gate before any consumer code is built. (Load-bearing on the whole system.)

  2. ULoadoutModifierDefinition validator. RESOLVEDUCradlLoadoutModifierDefinitionValidator added in this work (validates DisplayName/Icon/SlotTag/VisualsActor; auto-registers via UEditorValidatorBase). See Validators.

  3. 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.

  4. 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.

  5. 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.