0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI MAP_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
MAP_IMPLEMENTATION.md 4835 words ~22 min read Updated 2026-07-03

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 ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, the UFUNCTION declaration is unconditional; only the body is guarded — never combine the #if with the UFUNCTION line.
  • Per CLAUDE.md "validators in lockstep": any phase that touches FMapIconPresentationRow / UMapIconPresentationDataAsset, the MapBackgroundTexture / WorldBounds / MapPoiBake fields on ULevelDefinition, or UMapPoiBakeDataAsset / FMapBakedPoi updates the matching validator under Source/CRADLEditor/Validators/ in the same change (the bake store adds CradlMapPoiBakeValidator).
  • Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented Build.bat call (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; the PlayerArray path 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, per feedback_gameplay_tag_decl_minimal.md)Map.Icon root added too.
  • [x] Action.Modal.Map (both — referenced by the modal ability + HUD ModalPageClasses subscription)
  • [x] Action.Trigger.Modal.Map (both — mirrors the existing Action.Trigger.Modal.* convention for every Action.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 EditAnywhere config properties (DeveloperSettings convention; no EditDefaultsOnly).
  • [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 : UDataAssetTMap<FGameplayTag, FMapIconPresentationRow> Rows.
  • [x] Struct/class shells (declarations only, no behavior):
  • [x] FMapTrackable{ FVector WorldLocation; FGameplayTag IconTag; }Source/CRADL/Map/MapActorRegistry.h.
  • [x] UMapActorRegistry : UWorldSubsystem shell — Source/CRADL/Map/MapActorRegistry.h.
  • [x] UMapTrackerComponent : UActorComponent shell, bReplicates = false, UPROPERTY(EditAnywhere) FGameplayTag MapIconTagSource/CRADL/Map/MapTrackerComponent.h (+.cpp for the ctor).
  • [x] UMapIconPresentationRegistry : UGameInstanceSubsystem shell — 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 (defaulted FBox2D(ForceInit) so the unset box is degenerate).
  • [x] Validator updates (in lockstep):
  • [x] New Source/CRADLEditor/Validators/CradlMapIconPresentationValidator.h — validates each Rows entry has a non-empty DisplayName + a set Icon path resolves (Icon optional), mirroring UCradlCombatPresentationValidator (CradlCombatPresentationValidator.h). Added to CLAUDE.md's validated list.
  • [x] Extend Source/CRADLEditor/Validators/CradlLevelDefinitionValidator.cpp — enforce both-or-neither (MapBackgroundTexture set ⇔ non-degenerate WorldBounds) + dangling-texture check.

Verification.

  • Compile clean (Claude builds via Build.bat; links exit-0).
  • In-editor: create a UMapIconPresentationDataAsset with one row missing DisplayName → asset validation flags it; fill it → passes. Set MapBackgroundTexture on a ULevelDefinition with a degenerate WorldBounds → 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 LoadSynchronous of UCradlMapSettings::MapIconPresentation on first Resolve, mutable row cache, bResolveAttempted sentinel, leaf-name fallback DisplayName when a tag has no row (mirrors UCombatPresentationRegistry::Resolve + bResolveAttempted).
  • [x] Cheat command — ACradlPlayerController: Debug_MapResolveIcon <Tag> exec (dotted command names aren't valid C++ identifiers — the codebase uses the Debug_ prefix uniformly) → logs the resolved row (DisplayName, whether Icon soft-path is set, Color, DrawSize) at Warning level (per feedback_log_level_warning_for_diagnostics.md).

Verification.

  • Compile clean.
  • Author a UMapIconPresentationDataAsset with rows for Map.Icon.Player / Map.Icon.Enemy, point UCradlMapSettings::MapIconPresentation at it, run Map.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 in Initialize (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.md Icon 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 via AddUnique) / Unregister (RemoveSingleSwap); GatherTrackables merges both channels and applies the radius filter in one place, lazily compacting stale weak pointers during the sweep. UWorldSubsystem (world-scoped trackables, not UGameInstanceSubsystem). RadiusCm <= 0 disables the filter.
  • [x] Component self-registration — Source/CRADL/Map/MapTrackerComponent.h + .cpp: BeginPlayGetWorld()->GetSubsystem<UMapActorRegistry>()->Register(this); EndPlayUnregister. No authority gate — every peer builds its own local registry.
  • [x] Player channel: in GatherTrackables, iterate GameState->PlayerArray, resolve APlayerState::GetPawn() location, assign CradlTags::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.DumpTrackables shorthand isn't a valid C++ identifier) → logs every FMapTrackable within radius of the local pawn (world location + icon tag + dist) at Warning level.
  • [x] Cheat fixture — ACradlPlayerController: Debug_SpawnTestTracker exec → spawns a bare AActor (scene-component root so GetActorLocation reads back) with a runtime UMapTrackerComponent (tag Map.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, then Map.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 a Map.Icon.Player trackable on each peer; the local player does not (user-side runtime check).

Footguns.

  • Register on BeginPlay / unregister on EndPlay, not construction — streamed-out actors must drop out or the map paints stale weak pointers (MAP_SYSTEM.md Actor Data Source footgun).
  • Players are not registry-tracked — PlayerArray already 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 no ECC_GameTraceChannelN alias. The overlap returns visible geometry too, but the FindComponentByClass<UMapTrackerComponent> filter discards every non-tracker hit — exactly how UInteractionComponent discards non-IInteractable hits. The trackable classes (GatheringNode, CraftingStation, the terminals, QuestGiver, GroundItem, EnemyCharacter, TargetDummy) already SetCollisionResponseToChannel(ECC_Visibility, ECR_Block) for cursor traces, so the overlap finds them for free.
  • [x] Registry → query facade — MapActorRegistry.cpp: deleted Trackers / Register / Unregister; GatherTrackables runs World->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's UMapTrackerComponent (FindComponentByClass), dedups by owner (TSet<const AActor*>), and merges the unchanged PlayerArray channel (2D range-checked). Dropped the RadiusCm <= 0 unfiltered path — the full map no longer gathers, so the minimap always passes a positive radius.
  • [x] Component — MapTrackerComponent.cpp: removed BeginPlay/EndPlay entirely — no Register/Unregister, and no collision config (the owner already responds to ECC_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 a USphereComponent set to overlap ECC_Visibility so 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 assign MapIconTag to the per-class leaf — these do not rely on BP. The component is stored in a VisibleAnywhere TObjectPtr<UMapTrackerComponent> MapTracker so the per-placement override stays available. The set was extended (post-v1) to every IInteractable known class — a full scan of IInteractable implementers confirmed no built-in interactable should depend on a designer remembering the component. All five leaves are now native CradlTags::Map_Icon_* (matching Map.Icon.Player's existing "both" status — native registration also avoids any CDO-time string-lookup question); Map.Icon.Item / Map.Icon.NPC were 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 with ACraftingStation — not NPC)
  • [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 every IInteractable known 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 per feedback_no_clean_up_later.md.)
  • [x] Players are not wired here — their blips come from the PlayerArray channel in Phase 2, not a component.
  • [x] Debug_SpawnTestTracker left 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 MapIconTag in C++; EditAnywhere is 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] FTimerHandle refresh set from UCradlMapSettings::MinimapRefreshInterval; on tick: GatherTrackables(playerXY, MinimapRadiusCm) → project each through NorthVector into a mutable TArray<FMapBlip>Invalidate(EInvalidateWidgetReason::Paint).
  • [x] Projection helper (ProjectToMinimap): top-down ortho, NorthVector = screen-up, orthogonal (East = (-N.Y, N.X)) = screen-right, scaled by PixelRadius / MinimapRadiusCm * zoom. Never hardcodes the axis.
  • [x] NativePaint (const, not NativeOnPaint — that's the engine method name on UUserWidget) draws the blip array via FSlateDrawElement::MakeBox over each blip's FSlateBrush — reads the mutable member, 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 by MinimapMinZoom / MinimapMaxZoom; scales projection only, never enables panning.
  • [x] OnVisibilityChanged (the LoadoutPreview precedent, not a NativeVisibilityChanged override — UUserWidget exposes the dynamic delegate) clears the timer when collapsed, re-arms when shown.
  • [x] Icon brushes async-loaded — soft Icon textures 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 via FStreamableManager::RequestAsyncLoad, swapping to DrawAs=Image on 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> Minimap alongside MessageLog / PlayerHUD / LoadoutPreview. The minimap self-binds; the HUD does not pump it (the LoadoutPreview precedent).

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.

  • NativeOnPaint is const — blip array is a mutable member populated by the non-const refresh step (MAP_SYSTEM.md Render Strategy footgun).
  • Don't add a per-blip UWidget and reposition it — blips are draw elements, not widgets; a child-widget pool reintroduces the churn this strategy avoids.
  • Designer-tunable UPROPERTYs use EditAnywhere, not EditDefaultsOnly, or they vanish from the Designer Details panel (feedback_umg_widget_uproperty_edit_specifier.md).
  • Never name a local Slot in this widget subclass — it shadows UWidget::Slot (feedback_slot_shadows_uwidget.md); use BlipSlot / 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 WorldMapIconTag alongside MapIconTag. Empty by default — presence is the bake-membership signal (no bBakeToFullMap bool, per feedback_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 the Map.WorldIcon root + the C++-named leaves (dual native+ini, like Map.Icon.Player): Map.WorldIcon.Bank, .Store, .Loadout, .Anvil, .QuestGiver.
  • [ ] Presentation rows (content): add Map.WorldIcon.* rows to the existing UMapIconPresentationDataAsset (no new asset). CradlMapIconPresentationValidator already validates rows by entry, not a Map.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 : UDataAsset holding TArray<FMapBakedPoi> Pois (+ SourceLevel / BakedAtUtc for the staleness check).
  • [x] Level store ref — Source/CRADL/Levels/CradlLevelDefinition.h: added TSoftObjectPtr<UMapPoiBakeDataAsset> MapPoiBake. Extended CradlLevelDefinitionValidator.cpp (lockstep): a set MapPoiBake requires MapBackgroundTexture + non-degenerate WorldBounds (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] ABankTerminalMap.WorldIcon.Bank; ALoadoutTerminalMap.WorldIcon.Loadout; AStoreTerminalMap.WorldIcon.Store
  • [x] ACraftingStationMap.WorldIcon.Anvil
  • [x] AQuestGiverActorMap.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()) → its ULevelDefinition (by the forward Level soft pointer) → MapPoiBake store; iterates the world's UMapTrackerComponents, snapshots set-WorldMapIconTag actors' XY+tag, marks the store dirty. Logs every included and skipped tracker. Registered as Tools > CRADL > Bake Map POIs via UToolMenus in CRADLEditor.cpp (+ToolMenus/Slate/SlateCore deps). Reads WorldMapIconTag off the component — never names Map.WorldIcon.* leaves (reference_native_tag_no_cross_module_export.md).
  • [x] Bake validator — Source/CRADLEditor/Validators/CradlMapPoiBakeValidator.h (new): each Pois entry has a valid WorldMapIconTag and an in-WorldBounds position (bounds read from the referencing ULevelDefinition); flags a bake older than its SourceLevel package (staleness, as a warning). No CradlTags::X (cross-module). Added to CLAUDE.md's validated list.
  • [ ] Cheat command — ACradlPlayerController: Debug_DumpBakedPois exec → loads the active level's MapPoiBake and logs each FMapBakedPoi at 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 MapPoiBake populates with one FMapBakedPoi (the bank); the log lists the bank included and the node skipped. Move the bank without re-baking → CradlMapPoiBakeValidator flags staleness. Set MapPoiBake on 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 bBakeToFullMap bool; 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 loading MapPoiBake. The widget's pooled-UImage BlipCanvas strategy 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 / NativeOnActivated resolves the active ULevelDefinition via UCradlGameFlowSubsystemUAssetManager (feedback_carry_identity_forward.md); reads MapBackgroundTexture + WorldBounds + sync-loads MapPoiBake into BakeStore.
  • [x] Removed the GatherTrackables call (and the MapActorRegistry include). New BuildBakedLayer() paints the static layer once on activate (latched by bBakedLayerBuilt, pool indices [0, BakedBlipCount)): for each FMapBakedPoi, resolve WorldMapIconTag → row via UMapIconPresentationRegistry, normalize WorldXY into BlipCanvas pixels (NormalizeToCanvas), pool a UImage (the existing ApplyBlip path). Defers if geometry isn't laid out; the timer retries the one-time build.
  • [x] "You are here" dotRefreshPlayerDot() adds the local player explicitly as a Map.Icon.Player blip at the fixed slot BakedBlipCount, refreshed on the light timer (one GetPawn transform read) — only that one UImage moves; the baked layer is never rebuilt.
  • [x] A level with no chart → DeactivateWidget() on activate ("opens nothing"). A level with a chart but no MapPoiBake opens 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.Moving from the map page's cancel tags on the UCradlMapWidget BP / 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 with ModalTag = Action.Modal.Map, granted to the player; HUD WBP ModalPageClasses entry mapping Action.Modal.Map → the UCradlMapWidget WBP (router subscribes per-leaf).
  • [ ] Input route (BP content): InputToModalRoute entry on BP_CradlPlayerController keyed InputTag.UI.ToggleMap{ModalTag = Action.Modal.Map, TriggerTag = Action.Trigger.Modal.Map} + the matching UInputAction (the ToggleSkillLineage precedent; C++ route machinery already exists).
  • [ ] WBP + level content: author the UCradlMapWidget WBP (MapViewport / MapBackground / BlipCanvas, optional Btn_ZoomIn/Btn_ZoomOut); author per-level MapBackgroundTexture + WorldBounds, and run the Phase 5 bake to produce MapPoiBake.

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.InCombat is 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, never GetWorld()->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 to NativeOnPaint; 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 SActorCanvas edge-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.