CRADL Map Implementation
Companion to MAP_SYSTEM.md (the contract), COMBAT_IMPLEMENTATION.md (the presentation-registry build this mirrors), and ARCHITECTURE.md. This doc tracks the build order for v1 map: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what the map is; this doc says what we build first, what depends on what, and how we know each step works.
This is greenfield: a new Source/CRADL/Map/ module folder plus two new UI widgets, an in-editor FCradlMapPoiBaker Tools-menu utility + baked-store DataAsset, and additive fields on existing assets. Nothing here changes authoritative gameplay — the map is a strict local consumer of already-replicated actor state, and the full map is a strict consumer of an edit-time-baked landmark store (see MAP_SYSTEM.md).
Conventions
- Phase status legend:
[ ]not started ·[~]in progress ·[x]done ·[!]blocked / deferred. - Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
- Cheat commands: test fixtures land under
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, theUFUNCTIONdeclaration is unconditional; only the body is guarded — never combine the#ifwith theUFUNCTIONline. - Per CLAUDE.md "validators in lockstep": any phase that touches
FMapIconPresentationRow/UMapIconPresentationDataAsset, theMapBackgroundTexture/WorldBounds/MapPoiBakefields onULevelDefinition, orUMapPoiBakeDataAsset/FMapBakedPoiupdates the matching validator under Source/CRADLEditor/Validators/ in the same change (the bake store addsCradlMapPoiBakeValidator). - Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented
Build.batcall (UE: Build Editor (Development), run through the PowerShell tool) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job. - Replication: the contract's audit stands — no phase introduces a replicated property. Every new object is client-local cosmetic. If any task tempts a replicated field, stop and re-open the contract.
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag & data scaffolding | [x] |
All later phases |
| 1 | Icon presentation registry | [x] |
4, 6 (blip resolve) — parallel-able with 2 |
| 2 | Actor data source spine (→ overlap migration) | [x] |
3, 4 (minimap gather) |
| 3 | Trackable integration (C++ wiring) | [x] |
Minimap blips for 4; trackers for 5 to bake |
| 4 | Minimap render | [x] |
— |
| 5 | Landmark bake spine (in-editor utility) | [~] |
6 (baked store → render) — C++ done; per-level content (rows, bake run) remains |
| 6 | Full map render (baked landmarks + player) | [~] |
— C++ widget rewrite done; BP/content wiring remains |
| 7 | Polish & deferred (off-radar v2) | [!] |
— |
Phase 0 — Tag & Data Scaffolding
Goal. A compile-clean codebase with every tag, settings field, DataAsset shape, struct, and class shell the later phases reference — but no behavior. After this phase the project links and validates; nothing renders yet.
Rationale. Phase 0 is always tag & data scaffolding (the COMBAT pattern). Landing the tags, the UCradlMapSettings fields, the presentation row/DataAsset, the FMapTrackable/FMapBlip structs, the component/registry class shells, and the ULevelDefinition fields up front unblocks every behavioral phase without forcing later cross-cutting edits.
Tasks.
- [x] Map tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h:
- [x]
Map.Icon.Player(both — C++-declared; thePlayerArraypath names it in code) - [x]
Map.Icon.Enemy,Map.Icon.Resource,Map.Icon.Station,Map.Icon.Item,Map.Icon.NPC(.ini only — authored on components, never named in code, perfeedback_gameplay_tag_decl_minimal.md) —Map.Iconroot added too. - [x]
Action.Modal.Map(both — referenced by the modal ability + HUDModalPageClassessubscription) - [x]
Action.Trigger.Modal.Map(both — mirrors the existingAction.Trigger.Modal.*convention for everyAction.Modal.*leaf) - [x] Settings — Source/CRADL/Map/CradlMapSettings.h (new
UCradlMapSettings : UDeveloperSettings): - [x]
MinimapRefreshInterval(seconds,float) — per Open Question 3 resolution; matches the combat settings' seconds convention. - [x]
MinimapRadiusCm(float),MinimapMinZoom/MinimapMaxZoom(float). - [x]
NorthVector(FVector2D, default(1,0)) — per Open Question 1 resolution; the projection helper reads this, never a hardcoded axis. - [x]
MapIconPresentation(TSoftObjectPtr<UMapIconPresentationDataAsset>). - [x] All tunables
EditAnywhereconfig properties (DeveloperSettings convention; noEditDefaultsOnly). - [x] Presentation data — Source/CRADL/Map/MapIconPresentationDataAsset.h (new):
- [x]
FMapIconPresentationRow—{ FText DisplayName; TSoftObjectPtr<UTexture2D> Icon; FLinearColor Color = FLinearColor::White; float DrawSize = 16.f; }. - [x]
UMapIconPresentationDataAsset : UDataAsset—TMap<FGameplayTag, FMapIconPresentationRow> Rows. - [x] Struct/class shells (declarations only, no behavior):
- [x]
FMapTrackable—{ FVector WorldLocation; FGameplayTag IconTag; }— Source/CRADL/Map/MapActorRegistry.h. - [x]
UMapActorRegistry : UWorldSubsystemshell — Source/CRADL/Map/MapActorRegistry.h. - [x]
UMapTrackerComponent : UActorComponentshell,bReplicates = false,UPROPERTY(EditAnywhere) FGameplayTag MapIconTag— Source/CRADL/Map/MapTrackerComponent.h (+.cppfor the ctor). - [x]
UMapIconPresentationRegistry : UGameInstanceSubsystemshell — Source/CRADL/Map/MapIconPresentationRegistry.h. - [x]
FMapBlip—{ FVector2D LocalPos; FSlateBrush IconBrush; FLinearColor Tint; float Size; }— Source/CRADL/UI/CradlMinimapWidget.h. - [x] Level fields — Source/CRADL/Levels/CradlLevelDefinition.h:
- [x]
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Map") TSoftObjectPtr<UTexture2D> MapBackgroundTexture. - [x]
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Map") FBox2D WorldBounds(defaultedFBox2D(ForceInit)so the unset box is degenerate). - [x] Validator updates (in lockstep):
- [x] New Source/CRADLEditor/Validators/CradlMapIconPresentationValidator.h — validates each
Rowsentry has a non-emptyDisplayName+ a set Icon path resolves (Icon optional), mirroringUCradlCombatPresentationValidator(CradlCombatPresentationValidator.h). Added to CLAUDE.md's validated list. - [x] Extend Source/CRADLEditor/Validators/CradlLevelDefinitionValidator.cpp — enforce both-or-neither (
MapBackgroundTextureset ⇔ non-degenerateWorldBounds) + dangling-texture check.
Verification.
- Compile clean (Claude builds via
Build.bat; links exit-0). - In-editor: create a
UMapIconPresentationDataAssetwith one row missingDisplayName→ asset validation flags it; fill it → passes. SetMapBackgroundTextureon aULevelDefinitionwith a degenerateWorldBounds→ validation flags both-or-neither; set valid bounds → passes (user-side runtime check).
Exits. Phases 1–5 now have every tag, settings field, struct, and class shell they reference; no later phase needs to add a tag or field to an existing asset.
Phase 1 — Icon Presentation Registry
Goal. UMapIconPresentationRegistry::Resolve(FGameplayTag) returns a populated FMapIconPresentationRow for any authored Map.Icon.* tag, lazily loading the DataAsset on first call with a leaf-name fallback and a cached sentinel.
Rationale. One spine before its verbs: blip painting (Phases 4–5) needs icon resolution, but resolution is independent of the actor-gathering spine, so this is parallel-able with Phase 2. The pattern is fully proven — copy UCombatPresentationRegistry (CombatPresentationRegistry.h) shape exactly.
Tasks.
- [x] Resolve impl — Source/CRADL/Map/MapIconPresentationRegistry.h + .cpp: lazy
LoadSynchronousofUCradlMapSettings::MapIconPresentationon firstResolve,mutablerow cache,bResolveAttemptedsentinel, leaf-name fallbackDisplayNamewhen a tag has no row (mirrorsUCombatPresentationRegistry::Resolve+bResolveAttempted). - [x] Cheat command —
ACradlPlayerController:Debug_MapResolveIcon <Tag>exec (dotted command names aren't valid C++ identifiers — the codebase uses theDebug_prefix uniformly) → logs the resolved row (DisplayName, whether Icon soft-path is set, Color, DrawSize) at Warning level (perfeedback_log_level_warning_for_diagnostics.md).
Verification.
- Compile clean.
- Author a
UMapIconPresentationDataAssetwith rows forMap.Icon.Player/Map.Icon.Enemy, pointUCradlMapSettings::MapIconPresentationat it, runMap.ResolveIcon Map.Icon.Enemy→ log shows the authored row; run with an unrowed tag → log shows the leaf-name fallback (user-side runtime check).
Footguns.
- Resolve lazily on first
Resolve, not inInitialize(ARCH #14) — a broken soft path surfaces on first blip, caught by the Phase 0 validator, not at startup. - Don't remove the leaf-name fallback — it keeps dev flows rendering a non-blank label when the asset is unset (
MAP_SYSTEM.mdIcon Presentation footgun).
Phase 2 — Actor Data Source Spine
Goal. UMapActorRegistry::GatherTrackables(FVector2D Center, float RadiusCm, TArray<FMapTrackable>& Out) returns a flat, range-filtered array merging (a) UMapTrackerComponent world actors and (b) GameState->PlayerArray remote players assigned Map.Icon.Player. (The original channel-(a) mechanism — a self-registered flat list — is superseded by the overlap Revision at the end of this phase; the GatherTrackables signature is unchanged.)
Rationale. This is the one genuine net-new spine (the contract flags it as the only true addition). It lands before the widgets that consume it (one spine before its verbs). A cheat-spawned test tracker is the fixture (test fixture before content) so the gather path is provable before any real actor carries the component in Phase 3.
Tasks.
- [x] Registry — Source/CRADL/Map/MapActorRegistry.h + .cpp:
TArray<TWeakObjectPtr<UMapTrackerComponent>>;Register(idempotent viaAddUnique) /Unregister(RemoveSingleSwap);GatherTrackablesmerges both channels and applies the radius filter in one place, lazily compacting stale weak pointers during the sweep.UWorldSubsystem(world-scoped trackables, notUGameInstanceSubsystem).RadiusCm <= 0disables the filter. - [x] Component self-registration — Source/CRADL/Map/MapTrackerComponent.h + .cpp:
BeginPlay→GetWorld()->GetSubsystem<UMapActorRegistry>()->Register(this);EndPlay→Unregister. No authority gate — every peer builds its own local registry. - [x] Player channel: in
GatherTrackables, iterateGameState->PlayerArray, resolveAPlayerState::GetPawn()location, assignCradlTags::Map_Icon_Player, skip the local player's pawn (World->GetFirstPlayerController()->GetPawn()— it's the center reticle, not a blip). - [x] Cheat command —
ACradlPlayerController:Debug_DumpTrackables <RadiusCm>exec (Debug_ prefix per the codebase convention —Map.DumpTrackablesshorthand isn't a valid C++ identifier) → logs everyFMapTrackablewithin radius of the local pawn (world location + icon tag + dist) at Warning level. - [x] Cheat fixture —
ACradlPlayerController:Debug_SpawnTestTrackerexec → spawns a bareAActor(scene-component root soGetActorLocationreads back) with a runtimeUMapTrackerComponent(tagMap.Icon.Resource, resolved by string since it's .ini-only) at the player for gather verification before Phase 3 wiring exists.
Verification.
- Compile clean.
- Run
Map.SpawnTestTracker, thenMap.DumpTrackables 5000→ the test tracker appears in the log; walk out of radius and re-run → it's culled. In a 2-client PIE session, the remote player appears as aMap.Icon.Playertrackable on each peer; the local player does not (user-side runtime check).
Footguns.
- Register on
BeginPlay/ unregister onEndPlay, not construction — streamed-out actors must drop out or the map paints stale weak pointers (MAP_SYSTEM.mdActor Data Source footgun). - Players are not registry-tracked —
PlayerArrayalready maintains them; possession/respawn churn is the reason (don't add players to the registry). - The range filter is mandatory on every consumer, not optional — drawing every world actor is pointless.
Exits. Phase 4 (the minimap) has a single flat gather call with resolved icon tags to project and paint. (The full map no longer consumes this gather — it paints the offline-baked store instead; the Phase 3 trackers feed that bake in Phase 5.)
Revision — overlap-based gather on ECC_Visibility [x] (non-negotiable; supersedes the Registry + self-registration tasks above). Per MAP_SYSTEM.md decision 8: the flat TArray<TWeakObjectPtr> sweep is O(total registered trackers) every refresh — the exact per-actor cost a large level (Lumbridge↔Falador scale) can't afford. Replace it with an OverlapMultiByChannel query (O(in-radius)), mirroring UInteractionComponent (InteractionComponent.cpp) including its channel — overlap ECC_Visibility, filter to the marker component. The GatherTrackables signature and its Phase 4 call site are unchanged — this is an internal swap. (The interim "dedicated MapTrackable channel" plan was collapsed onto ECC_Visibility: every trackable interactable already blocks it, so reusing it needs zero new collision config and no per-component BeginPlay.)
- [x] No new channel — reuse
ECC_Visibility. No Config/DefaultEngine.ini edit and noECC_GameTraceChannelNalias. The overlap returns visible geometry too, but theFindComponentByClass<UMapTrackerComponent>filter discards every non-tracker hit — exactly howUInteractionComponentdiscards non-IInteractablehits. The trackable classes (GatheringNode,CraftingStation, the terminals,QuestGiver,GroundItem,EnemyCharacter,TargetDummy) alreadySetCollisionResponseToChannel(ECC_Visibility, ECR_Block)for cursor traces, so the overlap finds them for free. - [x] Registry → query facade — MapActorRegistry.cpp: deleted
Trackers/Register/Unregister;GatherTrackablesrunsWorld->OverlapMultiByChannel(... FCollisionShape::MakeSphere(Radius), ECC_Visibility ...)(sphere centered at the local pawn's Z so vertical offset doesn't eat the radius), resolves each hit owner'sUMapTrackerComponent(FindComponentByClass), dedups by owner (TSet<const AActor*>), and merges the unchangedPlayerArraychannel (2D range-checked). Dropped theRadiusCm <= 0unfiltered path — the full map no longer gathers, so the minimap always passes a positive radius. - [x] Component — MapTrackerComponent.cpp: removed
BeginPlay/EndPlayentirely — noRegister/Unregister, and no collision config (the owner already responds toECC_Visibility). The component is now a pure tag marker; a streamed-out actor's collision leaves the physics scene on its own. - [x] Test fixture —
Debug_SpawnTestTracker: the spawned actor now roots aUSphereComponentset to overlapECC_Visibilityso the gather query can find it — a bare scene-root actor has no collision and wouldn't be returned.
Revised verification. Debug_SpawnTestTracker + Debug_DumpTrackables 5000 → the test tracker appears; walk out of radius → culled (now via the overlap shape, not a flat distance test). 2-client PIE: the remote player still appears, the local does not. Confirm via a level with many trackers that there is no per-tick full-array sweep — the overlap visits only in-radius colliders (user-side runtime check).
Revised footguns. Reuse ECC_Visibility then filter by component (a broad channel returns all geometry — discard non-tracker hits, as UInteractionComponent does); the owner must respond to ECC_Visibility (known trackables do; an ad-hoc BP actor with collision off won't be found); overlap is 3D — use a tall capsule if the level stacks vertically (MAP_SYSTEM.md Actor Data Source footguns); dedup per owner (multi-primitive actors hit more than once); don't reintroduce the registration list.
Phase 3 — Trackable Integration (C++ Wiring)
Goal. The known trackable classes carry UMapTrackerComponent with the correct Map.Icon.* default from C++, so Map.DumpTrackables reports actual nodes, enemies, and stations — not just the test fixture — without relying on any Blueprint override. Ad-hoc content actors remain free to opt in via BP.
Rationale. Per Open Question 2's resolution (hybrid), the core set is guaranteed in C++ — known classes must never depend on a designer remembering to add the component; ad-hoc content actors keep the BP add-path as a supported escape hatch. This is content-bounded wiring that depends on the Phase 2 spine; it is the "verbs land after the spine" content step.
Tasks.
- [x] For each known trackable actor class, in the C++ constructor:
CreateDefaultSubobject<UMapTrackerComponent>(...)and assignMapIconTagto the per-class leaf — these do not rely on BP. The component is stored in aVisibleAnywhereTObjectPtr<UMapTrackerComponent> MapTrackerso the per-placement override stays available. The set was extended (post-v1) to everyIInteractableknown class — a full scan ofIInteractableimplementers confirmed no built-in interactable should depend on a designer remembering the component. All five leaves are now nativeCradlTags::Map_Icon_*(matchingMap.Icon.Player's existing "both" status — native registration also avoids any CDO-time string-lookup question);Map.Icon.Item/Map.Icon.NPCwere promoted from.ini-only as part of this scan: - [x] Gathering node (AGatheringNode) →
Map.Icon.Resource - [x] Crafting station (ACraftingStation) →
Map.Icon.Station - [x] Enemy (AEnemyCharacter) →
Map.Icon.Enemy - [x] Ground item (AGroundItem) →
Map.Icon.Item - [x] Bank / Loadout / Store terminals (ABankTerminal, ALoadoutTerminal, AStoreTerminal) →
Map.Icon.Station(fixed interactive terminals read as Stations, consistent withACraftingStation— notNPC) - [x] Quest giver (AQuestGiverActor) →
Map.Icon.NPC - [x] Target dummy (ATargetDummy) →
Map.Icon.Enemy(a combat target; draws alongside real enemies) - [x] Ad-hoc / content actors: the BP add-path remains a supported escape hatch for one-off content actors that aren't one of the known C++ classes above — add the component in Blueprint and author any
Map.Icon.*leaf there. With everyIInteractableknown class now C++-guaranteed, there is no remaining built-in interactable that draws only via BP. (The split is documented here so there's no silent gap perfeedback_no_clean_up_later.md.) - [x] Players are not wired here — their blips come from the
PlayerArraychannel in Phase 2, not a component. - [x]
Debug_SpawnTestTrackerleft guarded under#if !UE_BUILD_SHIPPING— still useful for exercising the gather path away from real content.
Verification.
- Compile clean.
- Stand near a gathering node + an enemy in PIE (no BP edits applied), run
Map.DumpTrackables 5000→ both appear with their C++-default tags, resolving through Phase 1's registry. Add the component to a content BP (e.g. a ground item) → it also appears (user-side runtime check).
Footguns.
- Known classes set
MapIconTagin C++;EditAnywhereis kept for the BP-opt-in path and for deliberate overrides — but a known class must not be drawable only because a BP set the tag (Open Question 2 resolution). - Content rules for whether depleted nodes / unowned ground items draw are presentation/spawn-lifetime decisions bounded by the
Map.Icon.*row set — enumerate them here as they're decided; they are not code-shape changes.
Phase 4 — Minimap Render
Goal. A persistent minimap: player-centered, north-up (via NorthVector), painting in-radius blips in a single NativeOnPaint pass, with +/- zoom, a static center reticle, a settings-driven refresh timer, and zero work when collapsed.
Rationale. Parallel-able with the full-map track (Phases 5–6) — the minimap consumes Phases 1–3 and depends on neither the bake spine nor the render. The minimap is the simpler render surface (no per-level data, no modal router), so it proves the paint pipeline first.
Tasks.
- [x] Widget — Source/CRADL/UI/CradlMinimapWidget.h + .cpp (
UCradlUserWidget): - [x]
FTimerHandlerefresh set fromUCradlMapSettings::MinimapRefreshInterval; on tick:GatherTrackables(playerXY, MinimapRadiusCm)→ project each throughNorthVectorinto amutable TArray<FMapBlip>→Invalidate(EInvalidateWidgetReason::Paint). - [x] Projection helper (
ProjectToMinimap): top-down ortho,NorthVector= screen-up, orthogonal (East = (-N.Y, N.X)) = screen-right, scaled byPixelRadius / MinimapRadiusCm * zoom. Never hardcodes the axis. - [x]
NativePaint(const, notNativeOnPaint— that's the engine method name onUUserWidget) draws the blip array viaFSlateDrawElement::MakeBoxover each blip'sFSlateBrush— reads themutablemember, never computes blips in paint. - [x] Static center reticle for the local player (a draw element; tinted-box fallback when no brush authored).
- [x] Zoom (
AdjustZoom/SetZoom, BlueprintCallable for WBP +/- buttons) bounded byMinimapMinZoom/MinimapMaxZoom; scales projection only, never enables panning. - [x]
OnVisibilityChanged(theLoadoutPreviewprecedent, not aNativeVisibilityChangedoverride —UUserWidgetexposes the dynamic delegate) clears the timer when collapsed, re-arms when shown. - [x] Icon brushes async-loaded — soft
Icontextures can't be sync-loaded in the refresh tick (CLAUDE.md). Each tag's brush is built once (Color-tinted box) and the texture streamed in viaFStreamableManager::RequestAsyncLoad, swapping toDrawAs=Imageon completion. No flash past the first refresh; matches the Phase 7 "icon pre-warm" deferral. - [x] Mount — Source/CRADL/UI/CradlHUDLayout.h: new
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<UCradlMinimapWidget> MinimapalongsideMessageLog/PlayerHUD/LoadoutPreview. The minimap self-binds; the HUD does not pump it (theLoadoutPreviewprecedent).
Verification.
- Compile clean.
- In PIE the minimap shows the center reticle and in-radius blips (test tracker, enemies, remote players); blips track actor movement; north stays up; +/- zoom scales the field without panning; collapsing the minimap stops the refresh (no blip updates while hidden) (user-side runtime check).
Footguns.
NativeOnPaintisconst— blip array is amutablemember populated by the non-const refresh step (MAP_SYSTEM.mdRender Strategy footgun).- Don't add a per-blip
UWidgetand reposition it — blips are draw elements, not widgets; a child-widget pool reintroduces the churn this strategy avoids. - Designer-tunable
UPROPERTYs useEditAnywhere, notEditDefaultsOnly, or they vanish from the Designer Details panel (feedback_umg_widget_uproperty_edit_specifier.md). - Never name a local
Slotin this widget subclass — it shadowsUWidget::Slot(feedback_slot_shadows_uwidget.md); useBlipSlot/IconSlot.
Phase 5 — Landmark Bake Spine
Goal. An in-editor "Bake Map POIs" Tools-menu utility produces a per-level UMapPoiBakeDataAsset from placed landmark trackers, validated and ready for the full map to consume. No rendering yet — after this phase the bake runs from the open editor world, the store populates, and validation flags an out-of-bounds POI and a stale bake.
Rationale. One spine before its verb (the COMBAT pattern): the full map (Phase 6) paints the baked store, so the store must exist and be provably complete before the render phase. Revised from the contract's original UCommandlet plan (per user direction): the bake is an in-editor utility, not a headless commandlet. A commandlet's whole risk was force-loading World-Partition cells to avoid a silent partial bake; an in-editor utility bakes the world the designer already has open and loaded — "bake what you see" — eliminating that failure mode. See MAP_SYSTEM.md.
Tasks.
- [x] Second tracker tag — Source/CRADL/Map/MapTrackerComponent.h: added
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Map", meta=(Categories="Map.WorldIcon")) FGameplayTag WorldMapIconTagalongsideMapIconTag. Empty by default — presence is the bake-membership signal (nobBakeToFullMapbool, perfeedback_push_back_on_duplicate_identity.md). Runtime never reads it; only the bake (edit-time) and validator consume it. - [x] World-icon tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h (+
.cpp): added theMap.WorldIconroot + the C++-named leaves (dual native+ini, likeMap.Icon.Player):Map.WorldIcon.Bank,.Store,.Loadout,.Anvil,.QuestGiver. - [ ] Presentation rows (content): add
Map.WorldIcon.*rows to the existingUMapIconPresentationDataAsset(no new asset).CradlMapIconPresentationValidatoralready validates rows by entry, not aMap.Icon-rooted filter — no widening needed. (Content — author per level.) - [x] Baked store — Source/CRADL/Map/MapPoiBakeDataAsset.h (new):
FMapBakedPoi { FVector2D WorldXY; FGameplayTag WorldMapIconTag; }+UMapPoiBakeDataAsset : UDataAssetholdingTArray<FMapBakedPoi> Pois(+SourceLevel/BakedAtUtcfor the staleness check). - [x] Level store ref — Source/CRADL/Levels/CradlLevelDefinition.h: added
TSoftObjectPtr<UMapPoiBakeDataAsset> MapPoiBake. Extended CradlLevelDefinitionValidator.cpp (lockstep): a setMapPoiBakerequiresMapBackgroundTexture+ non-degenerateWorldBounds(a landmark store needs a chart to draw on) + dangling-ref check. - [x] Constructor pass — landmark known classes set
WorldMapIconTag: the C++ default that decides bake membership. - [x] ABankTerminal →
Map.WorldIcon.Bank; ALoadoutTerminal →Map.WorldIcon.Loadout; AStoreTerminal →Map.WorldIcon.Store - [x] ACraftingStation →
Map.WorldIcon.Anvil - [x] AQuestGiverActor →
Map.WorldIcon.QuestGiver - [x] Left empty (minimap-only, not baked): AGatheringNode, AGroundItem, AEnemyCharacter, ATargetDummy. (Each constructor comment states why, per
feedback_no_clean_up_later.md.) - [x] In-editor bake utility — Source/CRADLEditor/MapBake/CradlMapPoiBaker.h +
.cpp(new):FCradlMapPoiBaker::BakeCurrentLevel()resolves the open editor world (GEditor->GetEditorWorldContext().World()) → itsULevelDefinition(by the forwardLevelsoft pointer) →MapPoiBakestore; iterates the world'sUMapTrackerComponents, snapshots set-WorldMapIconTagactors' XY+tag, marks the store dirty. Logs every included and skipped tracker. Registered as Tools > CRADL > Bake Map POIs viaUToolMenusin CRADLEditor.cpp (+ToolMenus/Slate/SlateCoredeps). ReadsWorldMapIconTagoff the component — never namesMap.WorldIcon.*leaves (reference_native_tag_no_cross_module_export.md). - [x] Bake validator — Source/CRADLEditor/Validators/CradlMapPoiBakeValidator.h (new): each
Poisentry has a validWorldMapIconTagand an in-WorldBoundsposition (bounds read from the referencingULevelDefinition); flags a bake older than itsSourceLevelpackage (staleness, as a warning). NoCradlTags::X(cross-module). Added to CLAUDE.md's validated list. - [ ] Cheat command —
ACradlPlayerController:Debug_DumpBakedPoisexec → loads the active level'sMapPoiBakeand logs eachFMapBakedPoiat Warning level for a runtime sanity check. (Deferred — the bake utility's own per-actor logging covers verification for now.)
Verification.
- [x] Compile clean (Claude builds via
Build.bat; links exit-0). - Place a bank + a gathering node in a level (all WP cells loaded), run Tools > CRADL > Bake Map POIs → the level's
MapPoiBakepopulates with oneFMapBakedPoi(the bank); the log lists the bank included and the node skipped. Move the bank without re-baking →CradlMapPoiBakeValidatorflags staleness. SetMapPoiBakeon a level with no chart → the level validator flags it (user-side runtime check).
Footguns.
- The bake covers only loaded actors. The utility iterates the open editor world, so unloaded WP cells aren't baked — far safer than a headless naive load (no silent partial bake), but the designer must load all WP cells first. The utility logs that reminder and every included/skipped tracker.
- Presence gates membership — no
bBakeToFullMapbool; the tag being set is the flag and its value is the icon. - Editor modules don't link native tag symbols — the validator resolves tags by validity only and the baker reads the tag off the component; neither names
CradlTags::Map_WorldIcon_*(reference_native_tag_no_cross_module_export.md).
Exits. Phase 6 has a populated, validated per-level landmark store to load and paint.
Phase 6 — Full Map Render (baked landmarks + player)
Goal. The full-level map opens as a modal page on UI.Layer.GameMenu via Action.Modal.Map, painting the baked landmark layer + a live "you are here" dot over the per-level background, with pan and zoom, ESC-close, in-combat gating — and movement does not cancel it (the player can walk while reading the map).
Rationale. The verb after the bake spine: it consumes Phase 5's MapPoiBake store. Reuses the built modal router (SubscribeToModalTags / HandleModalTagChanged + UCradlModalAbility) so focus/ESC/gating come for free; the one inherited behavior deliberately overridden is movement-cancel (Status.Moving stripped, the skill-lineage precedent). See MAP_SYSTEM.md.
Pre-work.
- This phase replaced the existing live-gather full map. CradlMapWidget.cpp previously called
UMapActorRegistry::GatherTrackables(..., RadiusCm = -1, ...)to paint every world actor on a looping timer — that path is removed in favor of loadingMapPoiBake. The widget's pooled-UImageBlipCanvasstrategy and pan/zoom/clip scaffolding are retained (the contract blesses pooled child widgets for this static layer). The modal/input scaffolding (InputTag.UI.ToggleMap,Action.Modal.Map,Action.Trigger.Modal.Map) already exists and is reused as-is.
Tasks.
- [x] Widget rewrite — Source/CRADL/UI/CradlMapWidget.h + .cpp:
- [x]
BindPlayer/NativeOnActivatedresolves the activeULevelDefinitionviaUCradlGameFlowSubsystem→UAssetManager(feedback_carry_identity_forward.md); readsMapBackgroundTexture+WorldBounds+ sync-loadsMapPoiBakeintoBakeStore. - [x] Removed the
GatherTrackablescall (and theMapActorRegistryinclude). NewBuildBakedLayer()paints the static layer once on activate (latched bybBakedLayerBuilt, pool indices[0, BakedBlipCount)): for eachFMapBakedPoi, resolveWorldMapIconTag→ row viaUMapIconPresentationRegistry, normalizeWorldXYintoBlipCanvaspixels (NormalizeToCanvas), pool aUImage(the existingApplyBlippath). Defers if geometry isn't laid out; the timer retries the one-time build. - [x] "You are here" dot —
RefreshPlayerDot()adds the local player explicitly as aMap.Icon.Playerblip at the fixed slotBakedBlipCount, refreshed on the light timer (oneGetPawntransform read) — only that oneUImagemoves; the baked layer is never rebuilt. - [x] A level with no chart →
DeactivateWidget()on activate ("opens nothing"). A level with a chart but noMapPoiBakeopens and paints only the player dot. - [x] Zoom-button child delegates stay bound in
NativeConstruct(feedback_bind_child_delegate_in_nativeconstruct.md) — unchanged from the prior scaffolding. - [ ] Movement-cancel override (BP content): strip
Status.Movingfrom the map page's cancel tags on theUCradlMapWidgetBP / its modal ability override — the skill-lineage precedent — so walking doesn't close the map. (BP/content — documented here so there's no silent gap.) - [ ] Ability + tag routing (BP content):
UCradlModalAbility(CradlModalAbility.h) BP subclass withModalTag = Action.Modal.Map, granted to the player; HUD WBPModalPageClassesentry mappingAction.Modal.Map→ theUCradlMapWidgetWBP (router subscribes per-leaf). - [ ] Input route (BP content):
InputToModalRouteentry onBP_CradlPlayerControllerkeyedInputTag.UI.ToggleMap→{ModalTag = Action.Modal.Map, TriggerTag = Action.Trigger.Modal.Map}+ the matchingUInputAction(theToggleSkillLineageprecedent; C++ route machinery already exists). - [ ] WBP + level content: author the
UCradlMapWidgetWBP (MapViewport/MapBackground/BlipCanvas, optionalBtn_ZoomIn/Btn_ZoomOut); author per-levelMapBackgroundTexture+WorldBounds, and run the Phase 5 bake to produceMapPoiBake.
Verification.
- [x] Compile clean (Claude builds via
Build.bat; links exit-0). - On a level with background + bounds + a baked store, the hotkey opens the map showing the background, the baked landmarks in correct relative positions, and the "you are here" dot; walking moves the dot without closing the map; pan and zoom work; ESC closes; opening while
Status.InCombatis blocked by the inherited gate; gathering nodes / enemies do not appear (only baked landmarks). On a level with no map data, the hotkey opens nothing and the minimap still works (user-side runtime check — needs the BP/content tasks above + a bake run).
Footguns.
- Full map paints baked POIs + the local player only — never
GatherTrackables. The player dot is explicit; refresh only its slot, don't rebuild the baked layer per tick. - Resolve the level through
UCradlGameFlowSubsystem, neverGetWorld()->GetName()(feedback_carry_identity_forward.md). - Movement no longer cancels — ensure the cancel-tag strip is on the map page specifically, not the global modal default.
- Pooled child
UImages are the blessed strategy for this static layer (the contract's Render Strategy exception) — don't "fix" it back toNativeOnPaint; that footgun is the minimap's.
Phase 7 — Polish & Deferred
Status [!] — deferred. Off-radar indication is explicitly out of v1 scope per Open Question 4's resolution (v1 culls out-of-radius actors and renders nothing off-radar).
Candidate work (not scheduled):
- [!] Off-radar rim arrows (v2). Rim-clamped indicators for out-of-radius actors — the first v2 candidate if the minimap feels blind. Lyra's
SActorCanvasedge-clamp (SActorCanvas.h) is the reference (reference only — Lyra ships no minimap). - [ ] Icon pre-warm. Async-load the presentation textures on registry init if first-paint flash on map-open is objectionable (same caveat the combat/spell icon rows carry).
- [ ] Blip FX / range rings / labels. Cosmetic-only; client-side, no replication.
Verification. N/A until scheduled — each item ships with its own gate when promoted out of deferral.
Implementation doc drafted at MAP_IMPLEMENTATION.md. Review the phase table and per-phase verifications, then iterate via /design-system MAP with feedback.