Now · Updated 2552.09.29.18.30
CurrentFocus — AI Session Primer
Update this file at the end of each session. It is the first thing to read at the start of a new one.
Update this file at the end of each session. It is the first thing to read at the start of a new one.
#Session 2026-08-02 — reusable hard-light bridge material
- Created and editor-verified the procedural hard-light bridge set under
/Game/SystemLink/Environment/HardLightBridge/Materials: translucent surface master/default instance plus optional additive edge master/default instance.
ActivationProgressandReverseConstructionDirectionsupport progressive U-axis construction with
a hot leading edge. All requested color, pattern, flow, scanline, pulse, noise, Fresnel, opacity, and Depth Fade controls are exposed.
- Reproducible authoring and validation scripts:
Tools/create_hard_light_bridge_materials.py and Tools/verify_hard_light_bridge_materials.py.
- Rebuilt 2026-08-02 as a fully node-based graph (zero Custom HLSL), informed by the layering and
Frac/Sine/Power/Fresnel structure in SystemLinkCore's M_Textured_Fresnel_01.
- Full mesh/UV, Blueprint/C++, multiplayer, tuning, performance, and Niagara guidance:
Docs/HardLightBridgeMaterial.md.
- Maya sample bridge created by
Tools/build_hard_light_bridge_sample.py: editable scene and three FBXs
under SourceArt/.../HardLightBridge, with a 12 m × 4 m normalized-UV surface, separate additive edge strips, projector housings, and UCX collision. Imported meshes live in /Game/SystemLink/Environment/HardLightBridge/Meshes; surface and edge instances are assigned.
#▶ RESUME HERE (2026-09-29) — create mode and switching WORK in PIE; SL-3 has two rows left
Branch profile-edit, committed and pushed this session.
#✅ Working and PIE-verified
- Create mode end to end. The name row opens seeded on the first free name (table order) with Broadcast off;
settings rows edit a draft (BeginDraftProfile / CommitDraftProfile / DiscardDraftProfile); Ⓐ on the Create row → RevealValidation → CreateEditedProfile. A blank name now shows "cannot be empty" on Create.
CommitProfileNamesplit intoCreateEditedProfile/RenameEditedProfile; FunctionRedirect points the old
name at Rename (the only live BP call was the Edit pin).
USLButtonRow— action row (Create/Delete) that navigates with the settings rows. Broadcasts
OnRowActivated; shows the confirm glyph in InputActionWidget only while focused. Asset: WBP_SL_SettingsRow_Button.
- Switching profile from the picker:
Can Select→Load Profile→Refresh Profile List. **Stays on the
picker** (decided today, PlayerProfiles.md §9.2) — the Player N marker moving is the confirmation. SL-4 removed.
#⚠ Traps paid today
WBP_SL_ButtonRowhad been reparented toUSLButtonRow, which broke every CommonButton call on it
(WBP_SL_ProfileRow's Set Is Selected), and WBP_SL_ProfileRow got deleted chasing it. Fixed by giving the settings action row its own asset and restoring both from git. One asset cannot be both a CommonButton and a settings row.
- A BP dispatcher copied onto a reparented widget goes dead when the event that fired it (
On Clicked) no
longer exists on the new parent. Bind the C++ On Row Activated instead.
Get Dynamic MaterialinEvent Pre Constructputs aMID_into the Details panel, and editing that brush
saves the transient MID — the saved brush comes out EMPTY and every button draws the same (all rows "lit"). Guard with Is Design Time → False. Nine widgets share the pattern (ButtonRow, ButtonMainMenu, ButtonTile, SettingsRow_Button, SettingsRow_Rotator, TextRotatorRow, MenuButtonProto, HealthWidget, DamageOverlay).
#⏭ Next — SL-3's remainder
- The display-name row.
- The delete row:
CanDeleteEditedProfile→ refusal modal / confirm modal (on ModalStack — anything on the
same stack deactivates the screen) → DeleteEditedProfile.
#RESUME (2026-09-22) — profile name row WORKING and pushed; create mode is next
Branch profile-edit, pushed to 75531cad (matches origin). The editor was killed for low memory while idle — not a crash — so Saved/Autosaves/PackageRestoreData.json may reappear on next launch. Machine rebooted after.
#✅ Working and PIE-verified this session
- Profile name row validates live under the field.
USLProfileEditWidget::GetProfileNameErroris the
whole rule in one call — it wraps PreviewProfileName and suppresses the one thing the registry cannot know, that in edit mode the profile's own name genuinely does already exist. Empty means valid.
WBP_Settings_Boxgained aProfileNameSlotand the screen fills it. The box stays a dumb container;
the row is the screen's own variable, so Set Validator is a local call with no reach-through. This replaced an earlier attempt where the screen reached into the box for its row.
- Rename persists —
On Value Committed→Switch on ESLProfileEditMode→Commit Profile Name. The
switch matters: in create mode committing would write a profile per arrow press.
- Left stick adjusts any settings row (
USLSettingsRowWidget::NativeOnAnalogValueChanged), latched on a
threshold pair so one push is one step. USLRotatorRow had this free as a UCommonButtonBase; the text rotator is a plain UCommonUserWidget and saw only NativeOnKeyDown.
DT_SL_ProfileNamesreimported fromDocs/HaloDefaultNames.json, so theProfileNamecolumn carries
the 40 names instead of leaning on the row name. The reader already preferred that column.
- Ⓧ no longer opens the profile screen over a modal.
FSLJoinInputProcessorruns atPreGame, before
every widget, so USLModalWidget::bIsModal can never block it — NotifyEditRequested now asks. USLPrimaryGameLayout::IsLayerOccupied makes that question reusable.
- Main menu refreshes its identity card on
Event On Activated, so a rename is on the card when the edit
screen pops. A rename while the menu is already visible still will not refresh — that would want the registry's OnProfileRenamed rebroadcast.
#🔧 Uncommitted — built, NOT yet run
SLTextRow.h/.cpp only. Adds bHasBeenEdited: a pristine row is invalid but SILENT, so create mode does not open complaining that a blank name cannot be empty. IsValueValid() is unchanged, so a Create button gated on it still refuses. Seeding (SetValue with broadcast off) restores pristine, which also covers pooled rows. Build is current. No Blueprint change needed.
First thing to do: open Edit Profile — no error on open; clear the field — error appears. Then commit.
#⏭ Create mode, in order
- Verify the silent-pristine build above.
- Ⓐ Create — the
Createpin on the switch stays empty. A separate Create button calls
CreateEditedProfile (was CommitProfileName, split 2026-09-23), which creates from the draft, loads, claims, and flips the screen to Edit mode.
- Seed create mode on a suggestion rather than blank — what
GetAvailableProfileNamesis ordered for.
- Then SL-3's remainder: the display-name row, and the delete row
(CanDeleteEditedProfile → refusal modal / confirm modal → DeleteEditedProfile).
#⚠ Traps found the hard way
- A BP function bound to a RetVal delegate must name its output
ReturnValue. NamedOutput, the value
never lands in the delegate's return slot and C++ reads empty — so the validator silently reported every name as valid, and the error label never showed. This cost most of a session.
BlueprintImplementableEventalone is invisible in the graph.OnRowFocusChangedlacked
BlueprintCallable, so no settings row could override its own focus highlight — which is why WBP_SL_SettingsRow_Rotator had grown a hand-rolled OnFocusGained dispatcher to route around it. Now paired. That dispatcher is vestigial and should be deleted.
- Wiring
OnRowFocusChangedto the oldSetRotatorFocusevent is an infinite loop.
USLMenuFocusSubsystem::SetFocused has no early-out and re-fires HandleFocusChanged on every call, so anything that leads from the hook back to focus recurses. It also breaks pad navigation entirely, because every D-pad press trips the detector. Wire the hook straight to Set Border Focus.
- A successful rename calls
OnEditedProfileChanged()(SLProfileEditWidget.cpp:119). Wiring
Set Suggestions to that event rebuilds the pool on every rotate — the name just taken drops out, the old one drops in, and the row ping-pongs between two names. Seed suggestions ONCE.
- Custom events cannot return values, so a RetVal delegate needs a function picked in the Create Event
dropdown, not a generated custom event.
- ❌
CommonInput.EnableGamepadPlatformCursor=1is NOT the fix for the editor cursor after PIE. Tried and
reverted: it keeps the platform cursor alive during pad input, which then fights NativeOnMouseEnter's SetFocus() — cursor flicker and broken menu navigation. The editor-cursor issue stays SL-31, deferred to a packaged build as already decided.
#📋 Board
SL-3 still in now and close to done. The Ⓧ-over-modal fix and the analog stick were unplanned and are done.
#▶ RESUME HERE (2026-09-18) — SL-3 in flight on profile-edit; NOTHING COMMITTED, nothing PIE-tested
Branch profile-edit, off main at 37b7e232 (PR #47). Zero commits — 15 modified files and 4 untracked are sitting in the working tree. Build is current and clean; the editor was closed before the reboot. Everything below compiles and none of it has been run.
#🔧 C++ built this session — all unverified
USLTextRotatorRow(SLTextRotatorRow.h/.cpp) — aUSLTextRowyou can also rotate through offered
values. Built on the text row because USLRotatorRow derives from UCommonRotator and shares no base with it below UCommonUserWidget. Left/Right cannot fight the caret: NativeOnKeyDown only runs while the ROW has focus, and BeginEdit moves focus into the box. Arrows bind in NativeConstruct (LeftArrowButton/RightArrowButton) the way USLRotatorRow does — no Blueprint wiring, but their Is Focusable must be OFF by hand. ⚠ Was briefly called USLNameRow; renamed before anything referenced it, so no CoreRedirects.
USLTextRownow fills its own text box.PushValueToTextBox()runs on seed, commit, revert and rotate.
ValueTextBox is BlueprintReadOnly — a BindWidget property becomes the C++ property instead of a BP variable, so without the specifier the graph cannot reach it at all. OnValueRefreshed is now optional.
FSLOnValidateText+SetValidator— returns the error message, empty means valid, matching
GetProfileNameResultText. A bound validator decides OUTRIGHT (MaxLength/bAllowEmpty skipped); it and a BP override of ValidateValue do not combine. SetValidator revalidates immediately.
CanDeleteEditedProfile(OutReason)—ExpandBoolAsExecs, so the refusal modal replaces the confirm
rather than following it. Shares FindSwitchTarget with SwitchAwayFrom so the modal cannot promise a deletion that then fails. Two distinct reasons: only-profile vs others-in-use.
GetAvailableProfileNames()— free pool names in table order. Deliberately unlike
CreateGeneratedProfile, which stays random (§8.14): being assigned a name wants variety, choosing one wants a stable order.
- Picker create seam removed —
RequestCreate()/OnCreateRequesteddeleted; the create button pushes
the edit screen itself. Create acts on no row, so there was no rule for C++ to own.
- E-key guard —
FSLJoinInputProcessor::IsTypingIntoTextField(). APreGamepreprocessor sees every key
before any widget, so bare E was opening the profile screen while typing. Matched by widget TYPE (SEditableText) because Slate exposes no text-input predicate. ⚠ This is the least-verified thing here.
#🎨 Blueprint state
WBP_SL_TextRotatorRow(untracked) —ValueTextBoxbound,OnTextChanged/OnTextCommittedwired to
Handle Text Changed/Handle Text Committed with Commit Method carried through (that pin is what makes Escape revert). OnValidationChanged → a Set Error Text helper. Event Pre Construct collapses the error label — now redundant, since C++ RefreshValidation runs after the BP PreConstruct event (UserWidget.cpp:1871) and sets the real state.
WBP_Settings_Box(untracked) — the extracted settings component. ⚠ Does not carry theSL_prefix the
sibling menu widgets do.
WBP_SL_ProfileEdit,WBP_SL_Profiles,WBP_SL_ButtonRow,WBP_SL_TextRowall modified.
#⏭ Next, in order
- Wire
Set Validatoron the name row fromWBP_SL_ProfileEdit. ⚠ Return empty whenIn Value
equals GetEditedProfileName() before calling PreviewProfileName, or the screen opens complaining that your own name already exists.
Get Available Profile Names→Set Suggestions.
- Delete row:
Can Delete Edited Profile→ refusal modal / confirm modal →Delete Edited Profile.
- Then actually run it. Nothing on this branch has been PIE-tested.
#❓ Open
- Settings: pushed screen or inline panel? §9.4 says the Settings row pushes
WBP_SL_Settings, which
reuses everything for free. WBP_Settings_Box exists, which suggests inline. Not decided.
- Settings row in create mode: §8.10 says "disabled" but not greyed-and-focusable vs skipped. Greyed-but-skipped
is the usual right answer on a pad.
- Commit-on-rotate: agreed for consistency with settings, but for a profile name it means a rename per
arrow press, and in create mode it must NOT create (§8.10's no-orphans rule). The row broadcasts OnValueCommitted; the screen decides what to do with it.
#📋 Board — 24 cards, now holds SL-3 alone
SL-7 folded into SL-3 as the delete row's behaviour (decided 2026-09-16: refuse with a modal, recorded in PlayerProfiles.md §9.5). Card-shape rule agreed the same day: don't card components whose only possible "done" is "it compiles" — card the feature, mention the component in its summary.
#Session 2026-09-16 — USLTextRow done; now was empty, next was SL-3
Branch text-row, off the merged main (ec3fae9b, PR #46). USLTextRow is built, wired into WBP_SL_TextRow and typed into.
- Keyboard-only, deliberately.
FGenericPlatformApplicationMisc::RequiresVirtualKeyboard()is
| `PLATFORM_HAS_TOUCH_MAIN_SCREEN | bAllowVirtualKeyboard` — both false on Windows, so there is no |
|---|
platform virtual keyboard to open and a gamepad cannot type into an EditableTextBox at all. SL-2's original blocker was therefore unsatisfiable as written. Every field today is player one's, and player one has the keyboard. SL-32 carries the on-screen keyboard, with BeginEdit as the override point.
- Escape reverts (it arrives as
ETextCommit::OnCleared); an invalid value reverts with its message left up;
validation is pushed every time rather than on change, so a pooled row cannot keep the last row's error.
- One commit delegate on purpose —
USLRotatorRow's three are inFootguns.mdfor a reason.
#📋 Board — now is EMPTY, and that is honest
SL-2 removed. SL-3 and SL-10 had their blockedBy cleared — both were waiting on USLTextRow. next opens SL-3 (finish WBP_SL_ProfileEdit), then SL-4, SL-7, SL-8.
⚠ Card scoping, agreed 2026-09-16: SL-2 was a bad card — a component with no consumer, whose only possible "done" was "it compiles", so it could never pass through verify honestly. Don't card components. Card the feature and mention the component in its summary.
#🧹 Still open
Saved/MovieRenders/is still 2.3 GB (gitignored — local disk only, not a repo problem).
- One Quit click in PIE would settle whether
WBP_SL_MainMenu's leftoverQuitConfirmstring is a live
reference or name-table residue. A dangling one fails at click time, not compile.
#Session 2026-09-14 (late) — picker selection SOLVED; create row removed
Branch main-menu-entries at ccac917b. Editor relaunched and left open. Nothing new committed — the tree still holds the UI assets plus today's C++. Saved/Autosaves/PackageRestoreData.json is now RestoreEnabled: false with an empty package list, so the OOM residue is cleared and there is no restore decision left to make.
#✅ The stale-selection bug — root cause found, fixed, verified in PIE
The picker lit the wrong row (the bottom one, once a second controller had a profile). It was never the picker's Blueprint graph — disconnecting that graph entirely changed nothing, which was the clue that broke it open.
There were two selection models, and every fix so far had been driving the invisible one.
USLListEntryWidgetderives fromUCommonUserWidget, notUCommonButtonBase.
UCommonListView::OnGenerateEntryWidgetInternal only wraps an entry in SCommonButtonTableRow when the class IsChildOf<UCommonButtonBase>() — so the button↔list bridge was never constructed.
- The highlight was painted by the
WBP_SL_ButtonRownested inside the row, whose
Should Select Upon Receiving Focus made focus the de-facto selection: whichever row was focused last won.
Set Selected Index,Clear SelectionandGetSelectedProfileItem()all acted on the list's own, unrendered
selection. The two were free to disagree, and did.
Fix, all five parts verified together: untick Should Select Upon Receiving Focus; forward the row's On Item Selection Changed straight into Set Is Selected on the inner button (unconditionally, Give Click Feedback off); reconnect Set List Items → Clear Selection → For Each; Get Desired Focus Target returns ProfileList and nothing else; Navigate To Index beside Set Selected Index.
⚠ Return Focus to Selection is a DEAD checkbox in 5.7 — plumbed UMG → Slate and then never read. Focus has to be aimed with NavigateToIndex, which is a deferred request and therefore the only thing that can work given entry widgets do not exist at activation time.
#✅ The "+ New Profile" row is gone
Creating has its own button now. Removed bIsCreateNewEntry, the bIncludeCreateEntry parameter and the sentinel block in BuildProfileListItems, with the stale comments that referenced them. Added USLProfilePickerWidget::RequestCreate() — a public BlueprintCallable raising the existing OnCreateRequested, mirroring RequestEdit/RequestSelect — because removing the row removed its only caller. No CoreRedirects: this is a removal, not a rename. Build clean.
#📋 Board
- SL-5 (mark the viewer's row) and SL-6 (focus the active profile) removed — verified, not assumed.
- SL-16 amended: there is no sentinel case left to test.
- SL-2 (
USLTextRow) is the only card innow, and still blocks SL-3, SL-9 and SL-10.
#🧹 Still owed
Print StringinWBP_SL_ProfileRow(yours).[ProfileTrace]on my side is removed.
Saved/MovieRenders/is still 2.3 GB — 913 PNG frames, a likely contributor to the OOM kill.
- ❓ Unanswered since this morning: was deleting
WBP_SL_QuitConfirm.uassetdeliberate?WBP_SL_MainMenu
still carries the string, and a stale reference fails at click time, not at compile. MI_UI_BodyBackground reads as a deliberate split into MI_UI_ButtonBodyBackground + MI_UI_PageBackground.
- Twelve
.uassetchanges and three untracked files are still uncommitted behind that question.
#📎 Recorded elsewhere
Two new entries in Docs/Footguns.md, in the UI section beside the existing §6.31 one: the missing selection bridge on a non-CommonButtonBase row, and the dead Return Focus to Selection flag.
#Session 2026-09-14 (earlier) — state recorded before a reboot
✅ Superseded by the block above: the selection bug is fixed and the package-restore question is moot.
Branch main-menu-entries, branched from the merged main (1ce37771, PR #45). One commit: 9879f183, not pushed. ⚠ The editor and the board server were killed by the OS for low memory, not closed — so the editor did not shut down cleanly and Saved/Autosaves/PackageRestoreData.json exists. Decide deliberately whether to accept the package-restore prompt on next launch.
#🔴 The one open bug — stale list selection
The picker selects the last profile from the previous time it was opened when nothing matches this time.
WBP_SL_Profiles::RefreshProfileList only ever sets a selected index when it finds the row with bIsClaimedByViewer. It never clears one, and the picker widget is pooled — so with no match the previous occupant's selection survives.
Fix: call Clear Selection on ProfileList between Set List Items and the For Each loop. (UListView::BP_ClearSelection, ListView.h:322.)
⚠ Also check the Viewer pin on Build Profile List Items is actually wired to Get Owning Player — a screenshot on 2026-09-11 showed it looking unconnected, and a null viewer makes bIsClaimedByViewer false for every row, which produces the same symptom by a different route.
#❓ Two questions blocking an asset commit
- Were these deletions deliberate?
WBP_SL_QuitConfirm.uassetandMI_UI_BodyBackground.uassetare
deleted in the working tree. WBP_SL_QuitConfirm is the modal MainMenuScreen.md records as the wired Quit flow; a stale reference to it fails at click time, not at compile.
- Whether the
Viewerpin above is wired.
Ten .uasset files are modified and waiting behind these.
#🧹 Cleanup owed before committing
[ProfileTrace]logging inSLProfileRegistrySubsystem.cpp(mine) — served its purpose, remove it.
Print StringinWBP_SL_ProfileRow(yours).
Saved/MovieRenders/MainIntro/is 2.3 GB — 913 PNG frames ofLS_MainIntroat 1920×1080. Probably a
contributor to the out-of-memory kill. Delete once the background frame you wanted is extracted.
#✅ Working and verified
- A second controller joins from the main menu; X (pad) or E (keyboard) opens that player's profile
picker on their own stack, driven by their own pad.
- Selection now comes from the list, not per-row buttons:
Set Selected Indexdriven by
bIsClaimedByViewer. That matches USLProfilePickerWidget's contract — the list's selection is what Ⓧ acts on, so GetSelectedProfileItem() agrees with what is lit.
- Player two backing out leaves player one able to navigate immediately (
RefreshActiveRootFocus).
- Claimed-by-others rows are dimmed and unselectable via
bCanSelect; the viewer's own row is selected.
#🔑 Decisions made 2026-09-11 (all recorded in PlayerProfiles.md §8.15)
- PROFILES stays on the main menu, reversing the 2026-09-10 "card is the only route" decision. Only player
one has a mouse, and another player's pad cannot reach player one's menu, so the button is unambiguously player one's route. The card was therefore never made clickable.
- Joining is gamepad-only; editing is not. A keyboard belongs to the player already sitting there, so an
invitation card pins its prompt to the Ⓐ glyph instead of following the current input type.
- ⚠ The E key is matched as a raw key in the input preprocessor, which runs below Enhanced Input. It must
be kept in step with IA_SL_UI_Edit's mapping in IMC_SL_UI by hand.
#⚠ CommonUI traps paid for on 2026-09-11 — all three were pooling
Worth one line in Footguns.md if it happens a fourth time: drive state from the value, never conditionally set it. Anything a pooled widget is not explicitly told stays behind from its previous occupant.
Set Is Selectedis a silent no-op unlessIs Selectableis ticked on the button
(if (bSelectable && bSelected != InSelected), CommonButtonBase.cpp:1031).
Set Is Selected(false)needsIs Toggleableas well —if (!InSelected && bToggleable). Without it a
recycled row can never be deselected.
- The list selection bug above.
Also: Get Desired Focus Target is asked at activation, before a list has generated any entry widgets, so GetActiveProfileEntryWidget returns null there and focus falls back to the list — which then focuses its first row, "+ New Profile". Driving the list's selection sidesteps it.
#⏭ Next
SL-2 (USLTextRow) is the only card in now, and blocks SL-3's name field plus SL-9's server name and SL-10's direct IP. SL-8 is in next, amended back to five entries (Host, Join, Profiles, Options, Quit) now that Profiles is staying.
SL-5 and SL-6 are still carded but look done — confirm and remove. The verify column still holds six profile checks (SL-11…SL-16) that have never been run.
#Session 2026-09-10 — SL-1 done and removed; SL-30 working; next is SL-8
Branch player-profiles, pushed at e6b0dacd. Two commits this session, working tree otherwise clean apart from the leftovers below. Build clean. Next up: SL-8, the main menu button list.
#✅ SL-1 removed from the board
Verified in PIE with two physical pads: connect → invitation card, hot-plug → live, Ⓐ on that pad → Player 2 with a generated profile, disconnect → invitation withdrawn, front end stays undivided, and a pad already connected at launch shows its invitation immediately. All four of the card's criteria, looked at rather than assumed, so the card was removed rather than parked in verify.
#✅ SL-30 works — X opens the picker for whoever pressed it
Press X on a joined card and the profile picker opens for that controller, on that player's own stack, driven by that player's own pad, over player one's untouched menu.
- Caught in the join input preprocessor by user index, never by UI focus. The zone is drawn in player
one's viewport, so a focusable row would act on player one's press whatever row it was.
ASLPlayerHUD::GetHUDForControllerresolves a controller to its own HUD. Confirmed by trace: every local
player gets its own ASLPlayerHUD, USLPrimaryGameLayout and CommonUI action router.
- First press wins, and the rule lives on the BP handler —
IsValid(EditScreen) AND IsActivated. CommonUI
pools widgets, so a popped screen is still a valid object; both checks are needed. A flag mirrored in C++ would survive a close it did not hear about and lock everyone out.
- The screen pushed is
WBP_SL_Profiles(the picker), not the edit screen — a player may want to switch
profile, not rename. §8.15's flow is picker-first with Ⓧ to edit from inside it.
Decision recorded in PlayerProfiles.md §8.15: the card is the only route to identity. No PROFILES menu entry. Nobody navigates to a card. This also retired the long-open question about reaching another player's ▸.
#⏭ Left on SL-30, both cosmetic
- The prompt on a joined card. It still reads
Player 2 / Stompywith no sign X does anything. The
Press A to Join card is the pattern. Wording: Press X to Change Profile — "edit" is wrong, the screen also switches profiles.
- Rename the BP variable
WbpProfileEdit→ it holds the picker, and that mismatch reads as a bug.
The C++ names under-describe it too (OnProfileEditRequested / NotifyEditRequested). Left alone deliberately — renaming the delegate breaks the two binding nodes in WBP_SL_MainMenu.
#🐛 Three traps paid for, all in Docs/Footguns.md
PostLoadMapWithWorldnever fires in PIE — broadcast only fromUEngine::LoadMap, and PIE duplicates
the world. The handler bound to it owned split suppression, so the front end had never actually suppressed the split in the editor (latent since 53b87e26). Now on FWorldDelegates::OnWorldInitializedActors.
- **A device-connection delegate reports only *changes*** — pads already connected never announce themselves
again, so Initialize must also scan GetAllConnectedInputDevices(). Disconnect broadcasts carry the unpaired user, so rescan rather than removing by device.
FUIInputConfigis per player; the viewport state it drives is shared. A second player's screen closing
reset player one's mouse capture. USLScreenWidget now hands it back — deferred until the closing screen has actually left its stack, because deactivation is the start of teardown. Same shape as ASLPlayerController::ScheduleGameplayInputRestoreCheck, and skipped entirely with one local player.
⚠ The lesson worth more than any of them: a cached policy flag from a map-load event fails silently. bInFrontEnd sat at false, every join was refused, and nothing logged it. Ask the live world instead.
#🅿 Parked: SL-31, the cursor
The front-end cursor shows only while moving once a controller is in use. Three theories, all wrong — input-type flipping, the PIE gamepad remap, viewport capture. CommonInput.EnableGamepadPlatformCursor 1 made it worse (keyboard glyphs on a pad) and is not the answer.
Only reproduces in PIE, where two local players share one Slate cursor in one window. Check it in a packaged build before spending more time. Do not re-derive the three dead ends.
#Board
now — SL-2 (USLTextRow) and SL-30. next opens with SL-8, amended to four entries (Host, Join, Options, Quit). ⚠ SL-8 must not drop PROFILES until SL-30's card prompt lands, or there is no route to profiles at all.
#Leftovers, deliberately uncommitted
MI_SL_HardLightBridge*.uasset×3 — incidental resaves from opening the project, unrelated to this work.
- Seven untracked
Docs/Screenshots/Site/PNGs — still unvetted.Tools/build_docs_site.pypublishes that
tree, and a billing screenshot was previously flagged as something that must not reach Docs/Site/.
#Session 2026-09-09 (late) — SL-1 local join works end to end in PIE
Branch player-profiles at 10aecb97. Still nothing committed — eleven files are modified in the working tree. Editor was left open.
#✅ A second controller can now join from the main menu
Verified in PIE with two physical pads, which is the thing that had never been possible before:
- Plug a pad in →
Controller 2 · press Ⓐ to joinappears live, no press needed.
- Press Ⓐ on that pad → it becomes Player 2 with a generated profile (Stompy).
- Unplug before joining → the invitation withdraws. A pad that sleeps after joining keeps its player.
- The front end stays undivided.
USLLocalJoinSubsystem grew four things: a subscription to IPlatformInputDeviceMapper::GetOnInputDeviceConnectionChange, a RefreshJoinCandidates() rescan, an accept path (NotifyJoinAccepted) driven from the same Slate preprocessor, and IsInFrontEnd(). ApplicationCore is now a private module dependency. Reasoning is in PlayerProfiles.md §8.15, which was revised: connection is now a join signal, reversing the 2026-09-04 decision that only input should be.
⚠ The Ⓐ that accepts is caught in the preprocessor, not a focusable row. The zone is drawn in player one's viewport, so a button there would join player two on player one's press.
#🐛 Two traps paid for, both now in Docs/Footguns.md
PostLoadMapWithWorldnever fires in PIE. Broadcast only fromUEngine::LoadMap; PIE duplicates the
world instead, and UnrealEd never broadcasts it. HandleMapLoaded was therefore dead code in the editor, which is why split suppression had never actually run — a latent bug from 53b87e26, not from this work. Rebound to FWorldDelegates::OnWorldInitializedActors, which fires on both paths.
- **A device-connection delegate only reports changes.** Pads already on the desk announced themselves
before the GameInstance existed and never announce again, so Initialize must also scan GetAllConnectedInputDevices(). Disconnect broadcasts carry the unpaired platform user, so rescan rather than removing by device.
The wider lesson, and the one that cost the session: a cached policy flag from a map-load event fails silently. bInFrontEnd sat at false, every join was refused, and nothing logged. It is now asked of the live world at the moment it matters.
#⚠ Still open on SL-1
- The startup display gap. With a pad connected before Play, the invitation row did not draw until some
unrelated input. The subsystem is not at fault — the log shows correct candidates 2.8s before any press (00:49:22.889), so it is downstream of GetIdentityRows. Look inside WBP_SL_MainMenu's Load Player Profiles, at where Card is assigned for a non-joined row.
- ⚠
Load Player Profilesmust stay onEvent Construct. Moving it toOn Initializedwas tried and
reverted: the designer-placed cards do not exist yet there, and it throws four Accessed None ... property Card errors at PIE start.
- Decide whether player one's cursor can reach player two's
▸. §8.15 still does not rule on it.
#🆕 SL-30 added
Player two can join but has no input, so they cannot change the name generated for them. ASLPlayerHUD is an AHUD — one per PlayerController — and creates its own USLPrimaryGameLayout (SLPlayerHUD.cpp:61), and CommonUI's action router is a LocalPlayer subsystem, so the plumbing largely exists; nothing has ever pushed a widget onto player two's stack. Confirm first that a second local player actually gets an ASLPlayerHUD in the menu map — that decides whether this is half a session or considerably more.
#Carried over, still true
Docs/KanbanBoard/app.js+README.mdstill uncommitted (the localStorage card-key backfill). The browser
may show Local changes; card edits will not appear until Board options → Reset local changes.
- Seven untracked screenshots under
Docs/Screenshots/Site/—Tools/build_docs_site.pypublishes that tree,
so vet them before the next site build.
#Session 2026-09-09 (earlier) — SL-1 identity zone: cards render, join flow unproven
Branch player-profiles at 10aecb97. Nothing committed this session — the working tree carries the Blueprint work and a board fix, both described below. Editor was closed cleanly before the restart.
#✅ The identity zone draws (SL-1)
WBP_PlayerCard and WBP_SL_MainMenu are modified in the working tree: the main menu now builds one player card per row returned by Get Identity Rows, and it works on screen.
The wiring, because it cost a wrong answer to find:
Get Owning Playerfeeds theViewerpin — neverGet Player Controller (Index 0), which returns
player one for every local player and would flag the wrong row as bIsViewer.
- That node is declared on
UWidget(Widget.h:879), notUUserWidget—UUserWidget::GetOwningPlayer
has no UFUNCTION. It is in the palette's Widget category next to Get Owning Local Player.
- Do not route through
Get Owning Local Player:ULocalPlayer::GetPlayerControllerhas noUFUNCTION,
so there is no node to chain to it.
- Cards come from a plain Vertical Box, cleared and rebuilt — not a
CommonListView. Max four rows, no
scrolling, and the list's entry pooling is what caused the profile-picker row-click bug.
#⚠ The join flow has never been exercised
Only one card shows with a keyboard and one pad, and that is correct: the pad is player one's, so GetUserIndex() is 0, HasLocalPlayer(0) is true, and there is nobody to invite. The invitation row (Controller 2 · press Ⓐ to join) needs a second physical controller — none was available.
So JoinLocalPlayer, LeaveLocalPlayer, the invitation card, and the split-on-match-load are all written, compiled, and unproven. This is exactly the failure mode the board's verify column exists for.
Offered, not built: a debug command SL.Join <index> that pushes a fake join candidate and one that calls Join Local Player directly, so both rows can be exercised without a second pad. SLDebugCommands.cpp has the pattern. This is the cheapest way to unblock verification.
#Still to do on SL-1
- Bind
On Local Players Changedand rebuild the cards in the handler. Bind inOn Initialized, and
unbind in Destruct — the subsystem is GameInstance-scoped, so it outlives the widget and survives map travel; a delegate left bound keeps the dead menu widget referenced across the load into a match.
- Decide whether player one's cursor can reach player two's
▸. Recommendation: only the viewer's own row
is focusable, or the host can reassign everyone's profile. PlayerProfiles.md §8.15 does not rule on it.
- Fix an overpromising comment:
SLLocalJoinSubsystem.h:76says the event fires when a controller "stops
asking", but nothing ever removes a stale candidate — JoinCandidates.Remove only runs on a successful join. Either drop the clause or add the timeout that would make it true.
#🔧 Board card numbers — fixed, uncommitted
Docs/KanbanBoard/app.js and README.md are modified. The board reads localStorage before cards.json (app.js:230), so a browser holding a copy saved before card keys existed kept rendering empty SL- slots; clearing the HTTP cache does not touch that store. The fix backfills missing keys from cards.json matched by card id, so restored numbers are the real ones rather than invented. Contributed by another tool, reviewed here, not yet committed.
⚠ The browser may still show Local changes in the header, meaning it is rendering its own stale card set. Card edits made here will not appear until Board options → Reset local changes.
#Housekeeping
Seven screenshots are untracked under Docs/Screenshots/Site/. Tools/build_docs_site.py publishes that tree, so check them before the next site build — an Xbox Game Pass billing screenshot was previously flagged for deletion and should not reach Docs/Site/.
#Session 2026-09-03 — profiles: backend done, picker works, edit screen next
Branch player-profiles at 5e4eb1d8, pushed. 23 commits ahead of origin/main, no PR opened yet. Build clean, 33/33 automation tests pass.
#✅ The picker works end to end
WBP_SL_Profiles lists real profiles, Ⓐ selects, Ⓧ edits, + New Profile creates — on pad and mouse, both routed through one implementation.
USLProfilePickerWidget— exists in C++ only becauseRegisterUIActionBindingis not BlueprintCallable.
Owns the Ⓧ binding and the routing (RequestEdit / RequestSelect / RequestSelectItem); layout and rows stay in Blueprint.
USLProfileEditWidget— one screen, two modes (§8.10).BeginCreate/BeginEdit,
CommitProfileName, display-name override, DeleteEditedProfile. WBP_SL_ProfileEdit exists but is a shell.
IA_SL_UI_Editmapped inIMC_SL_UI(X + should also getE), driving both the binding and the
command-bar prompt from one asset.
USLButtonBase::DisplayInputAction— an action a button shows without binding, which is what the
command bar needs. bAlwaysShowInputActionWidget exists too but only ever shows the default click action.
#✅ No more Default profile (2026-09-02)
Nobody at a LAN party is called Default. The first thing that needs a profile now generates one from a name pool — Donut, Caboose, Coffee Cake. Nothing is reserved, so every profile is renamable and deletable, and a whole class of special cases went away rather than moving. Full reasoning: PlayerProfiles.md §8.14.
⚠ Set the name table before testing: Project Settings ▸ System Link ▸ System Link Profiles ▸ ProfileNameTable, pointed at the imported names DataTable (FSLProfileNameRow). Without it, generated names fall back to "Player 2", "Player 3" — working, just dull, and easy to mistake for a bug.
This also killed guest profiles (§8.7, superseded): a joining player gets a real, persisting identity instantly, which is strictly better than a transient one.
#⚠ Traps paid this session — all in Docs/Footguns.md
- Restoring gameplay input on a suppression-count dip. A screen covered by another screen deactivates, so
the count hits zero during an ordinary push and CommonUI's transition holds it there for several ticks. Restoring there re-applied FInputModeGameOnly mid-handoff (cursor snapped to viewport centre, hover dead until a click). Both obvious fixes were wrong in opposite directions — a one-tick deferral does not span the transition, and gating on stack contents breaks the close instead, leaving input dead. It now waits for the ambiguity to resolve. ⚠ The cursor jumping to the viewport centre is the tell for mouse capture.
RemoveActionBindingis notUnregister. It stops the router honouring a binding but leaves the entry
in AllRegistrationsByHandle, so on the second visit TryCreate finds the corpse and refuses — the action works exactly once per session. Widgets are pooled, so it is the same object. USLModalWidget still has this pattern; left alone so a change there can be tested on its own.
- A list row whose root is a button swallows the click, so the list's
On Item Clickednever fires — and
on a pad it cannot, since Ⓐ activates the focused button. Route from the button (USLListEntryWidget::GetOwningScreen → RequestSelectItem), and never through the list's selection, which is a no-op when Selection Mode is None.
- Anything hung off HOVER is invisible to a controller. Hover has one source,
SCommonButton::OnMouseEnter;
focus never drives it. It bit twice because the highlight and the sound were wired at different times.
BP_MainMenuGameModenever setPlayerControllerClass, so the menu map spawned a stock
APlayerController, the input-suppression cast failed, IMC_SL_UI was never applied, and every focused button's glyph collapsed. Second time that map shipped a stock engine class (it ran a stock AHUD until 21db01eb).
#▶ NEXT
All outstanding work lives on the kanban board — Docs/KanbanBoard/cards.json, with Docs/KanbanBoard/README.md as its maintenance contract. Statuses are now / next / verify / blocked / later; verify means built but not proven, which is where this project's worst bugs have hidden.
It replaced six separate lists (this file's NEXT and Loose Ends, the build order in PlayerProfiles.md §8.9, ProfilesPIEChecklist.md, the phase plans and EditorTasks.md), which is why items kept leaking between them — and why the eight open gameplay tasks in EditorTasks.md were invisible from here.
This file is narrative only now: where we left off, what shipped, what it cost. What to do next is the board's job. Read both at session start — this for context, the board for the queue.
Highest value on the board right now: USLTextRow. One widget unblocks the profile edit screen, Host's server-name field and Join's direct-IP field — and the last two are the original Phase 1 goal.
⚠ Everything below this line is ARCHIVE. Each old resume note keeps its own ▶ NEXT and loose-ends
list as it stood on that date — there are nine of them, and they are not current. The live queue is
Docs/KanbanBoard/cards.json. Read these for how something came to be the way it is, never for what to do
next.
#Previous resume note (2026-08-29) — main menu merged; player profiles branch, backend done
Branch player-profiles at d08abda6, pushed and tracking. Two commits ahead of origin/main. mp-phase1-lan is merged (PR #44, 10 commits, 3f331b08) and can be deleted. Build clean, 32/32 automation tests pass.
#✅ Shipped and merged — the main menu works
- Deprecations cleared (
ca965e8f) — the C4996 pass from the previous session, committed.
USLSessionService::GetSessionResultText(189300ed) —MainMenuScreen.md§4's failure-text table
in one place, so the main menu and join screen don't each hand-author the same six-way switch.
- Plugin dependencies declared (
7e5f1aac) — the.upluginlisted only Niagara while the module needs
CommonUI, CommonInput, EnhancedInput, GameplayAbilities and OnlineSubsystemNull. They were resolving only because the engine enables them by default. UBT is now silent.
- Buttons: styling, focus highlight, glyph, sound (
9f1c1dc5,37e7281d). The main menu is a
WBP_SL_ButtonMainMenu + Style_ButtonMainMenu pair (the shared WBP_SL_Button is deleted, zero referencers); UI materials moved into Buttons/, TileButtons/, Frames/.
#✅ Shipped on the branch — the profiles backend
- Design (
2b337e9b) —PlayerProfiles.md§8 replaces "Not built yet". A profile is a person, not a
settings bag. Menu shape decided: PROFILES and OPTIONS are siblings, splitting per-player from per-machine along the same line as §3.3's persistence split. Corrections recorded in MenusAndOnline.md §2 and MainMenuScreen.md, which both still described one Settings screen carrying both.
USLProfileRegistrySubsystem+USLProfileIndexSaveGame(4c8ebee9) — machine-wide list of profiles:
enumerate, validate, create, rename, delete, claim/release, last-used, self-heal. 7 automation tests under SystemLink.Settings.ProfileRegistry.
- Wiring (
d08abda6) —USLPlayerProfileSubsystem::Initializeresolves a start profile through the
registry and claims it, Deinitialize releases, LoadProfile returns bool and refuses a profile another local player is on. Only the primary local player writes last-used.
#⚠ Traps paid this session
BP_MainMenuGameModenever setPlayerControllerClass, so the menu map spawned a stock
APlayerController. SetGameplayInputSuppressed casts to ASLPlayerController, that cast returned null, the early-out meant IMC_SL_UI was never applied, and with no live mapping context the focused button's action widget could not resolve an icon and collapsed itself. This map has now shipped a stock engine class twice — it ran a stock AHUD until 21db01eb. Check what a front-end map's GameMode actually spawns before debugging the widget.
- A pasted duplicate
UCommonActionWidgetworked while the real one didn't, which is what localised it:
only the instance named InputActionWidget is BindWidgetOptional-bound and therefore C++-driven through the IMC-dependent Enhanced Input path. An unbound copy renders its designer row's OverrideBrush and needs no mapping context. Two identical-looking glyphs, two resolution paths.
- Hover and focus are separate systems in CommonUI, and it bites twice. The highlight was moved to
OnFocusChanged in the morning; the navigation sound was still on On Hovered and stayed mouse-only, so the buttons lit silently. Anything driven from hover is invisible to a controller.
- The unity-namespace footgun landed exactly as written. The new registry test compiled standalone until
it was committed in 4c8ebee9, then rejoined the unity blob and its anonymous namespace collided with SLPlayerProfileSubsystemTest.cpp. Helpers now carry a Registry prefix.
LoadProfileInternalnever writes to disk — profiles only reach a file viaSaveProfile(). So the
bootstrap profile legitimately has no slot on a fresh install, the reconcile has to exempt it, and CreateProfile has to write the slot immediately or a new profile vanishes if the player changes nothing.
#▶ NEXT — PlayerProfiles.md §8.9, steps 3 onward
- PIE check on what's already in (do this first — it is untested). Checklist:
Docs/ProfilesPIEChecklist.md. Boot and confirm it resumes into the
right profile; add a second local player and confirm they do not land on player one's. ⚠ The step-2 wiring is not unit-tested: the registry lives on the game instance and the headless harness builds a ULocalPlayer without one, so every registry call is best-effort and the new paths never execute in tests.
WBP_SL_Profilespicker — pick / create / rename / delete.GetProfileNames,IsProfileClaimed,
GetClaimingPlayer, GetProfileNameResultText and OnProfileListChanged are all Blueprint-exposed. Claimed profiles show disabled. Delete confirms through USLModalWidget.
- Repoint the third main-menu button at Profiles, add
OPTIONSas the fourth. Five entries:
Host · Join · Profiles · Options · Quit. WBP_SL_Settings is the per-profile screen and moves under Profiles; video/scalability/audio go under Options.
- Guest (transient) profiles, then split-screen itself —
MenusAndOnline.md§9 items 1–4, 6.
#Loose ends
WBP_SL_JoinGamestill does not exist, so the Join button has nowhere to point. Host and Join are still
unwired — WBP_SL_MainMenu has only PushQuitConfirmation. Spec: MainMenuScreen.md §2–3.
InitializeForTesting(Slot)on the registry is the one production concession to testability. It exists
because the registry reads and writes a fixed index slot, so a test built the normal way would load and overwrite the player's real profile list. Do not "clean it up".
Defaultis fully reserved — cannot be created, renamed, or deleted. It protects the legacy-adoption
branch in LoadProfileInternal. The cost is a permanent Default row in the picker; hiding a never-modified one is a small follow-up if it grates.
bLogInputstill ON inWBP_SL_IntroSkip; modal mouse click-through still unverified (needs a hit-testable
scrim); WBP_SL_QuitConfirm still committed but unreferenced; the command-bar KBM contradiction still open (Docs/CommandBarBackButton.md).
Tools/CPU-audit scripts andDocs/Screenshots/Site/remain untracked.
#Previous resume note (2026-08-28, second session) — new machine; UE-deprecation prep; build clean
Branch mp-phase1-lan, checked out fresh on the second dev machine. origin/mp-phase1-lan is at a5c6d9d0, 4 commits ahead of origin/main. The 2026-08-28 section below is the live feature work and is unchanged and still the plan — this session was machine setup plus a deprecation pass, no feature work.
#✅ Done
- Branch pulled and building on this machine.
SystemLinkEditor Win64 Development→Result: Succeeded
in ~60 s. Binaries/ and Intermediate/ were already warm here, so no clean rebuild was needed. Editor was never launched.
- Cleared every C4996 deprecation — the plugin now compiles with ZERO warnings. All three warned that
SLGameplayAbility_Dash.cpp/SLGameplayAbility_DoubleJump.cpp—AbilityTags.AddTag(...)becameSLSettingsRowWidget.cpp:29—EKeys::Virtual_Accept→EKeys::Virtual_Gamepad_Accept.GetVirtualKey().- Five ability headers (
Dash,Death,Equip,Fire,Melee) — doc comments renamedAbilityTags
"your project will no longer compile" on the next engine release, so this is next-UE-release prep.
FGameplayTagContainer AssetTags = GetAssetTags(); → AddTag → SetAssetTags(AssetTags). ⚠ SetAssetTags ASSIGNS, it does not append, and it ensureMsgfs that it is only ever called during construction (GameplayAbility.cpp:1301). Seeding the local from GetAssetTags() is what keeps this correct if a base class ever starts adding tags of its own.
Behaviour is identical, confirmed from engine source: the old symbol was initialised from FPlatformInput::GetGamepadAcceptKey() (InputCoreTypes.cpp:420) and the new key registers that same function as its virtual key (line 725). The neighbouring Gamepad_FaceButton_Bottom test is redundant on Windows but still matters on platforms where accept is circle.
→ AssetTags. The editor already labels the property "AssetTags (Default AbilityTags)", so the text is right both now and after the engine rename.
- Verified with a third build after the header edits: still 0 warnings, 0 errors.
#⚠ State across the restart
- 🟡 The deprecation fixes are UNCOMMITTED — 8 files, +13/−8. All tracked C++/headers, no
.uasset,
so they survive the reboot on disk with full recovery. Commit them before starting the menu work.
- Untracked and unrelated to the game:
Tools/audit_replacement_cpu.ps1,Tools/replacement-cpu-audit.txt,
Tools/repair_windows_component_store.ps1, Tools/component-store-repair.txt. dev/ and tmp/ are gitignored on this branch.
- Scope note: this clears the deprecations the 5.7 compiler flags today. It is not a 5.8 readiness
audit — more will surface when the next release actually ships and can only be found by compiling against it.
- This machine IS the known dev rig — Alienware Aurora R16, service tag
3XC7Q54, i9-14900KF, BIOS
2.23.0. ✅ The CPU-degradation saga is OVER: Dell replaced the processor on 2026-08-28. The earlier note in this section said Turbo Boost had been "re-enabled by something" and should be restored to 0 — that was written before the replacement was known, and it is wrong now. Leave Turbo Boost ON (PERFBOOSTMODE AC 0x2, DC 0x0, PROCFREQMAX uncapped). Full builds run at boost clocks again instead of the 3.2 GHz base the workaround forced. ⚠ Stability under boost has zero hours of evidence — the 34 crash-free days were boost-off on the old chip. Last unexpected shutdown was 2026-07-25 16:29. If BSODs return, the fallback is to cap PROCFREQMAX ≈ 5000 MHz (downbin), not to disable boost and never a positive XTU voltage offset. Audit scripts + transcripts: Tools/audit_replacement_cpu.ps1, Tools/enable_cpu_boost.ps1.
#▶ NEXT — unchanged from the section below
- Commit the deprecation fixes.
- Wire the remaining three menu buttons. Host → `HostMatch("/Game/SystemLink/Levels/Level1/Level1",
ServerName, 8) — ⚠ on Success do nothing, the service broadcasts and then calls ServerTravel. Join → WBP_SL_JoinGame. Settings → WBP_SL_Settings`.
WBP_SL_JoinGame— direct IP first, server list second.
- Two-machine LAN test — and this machine is now the second one.
#Previous resume note (2026-08-28) — Intro skip + confirmation modal; Quit is wired end to end
Branch mp-phase1-lan, pushed. Build clean. Machine handoff: everything below is committed; the 2026-08-26 section under this one is now closed history and kept for the LAN-service context.
#✅ Shipped
USLIntroSkipWidget(new,Public/UI/+Private/UI/) +WBP_SL_IntroSkip— "press any button to
skip" over LS_MainIntro. Any key, pad button, or (optionally) click fires a one-shot OnSkipRequested; the Level Blueprint does the actual Go to End and Stop and the menu push.
- MainMenu Level Blueprint restructured. The
Delay 6.0is gone (the hold now lives inside
LS_MainIntro itself), and both the sequence player's OnFinished and the skip path funnel through one ShowMainMenu custom event behind a DoOnce.
USLCommonActivatableWidget::PushQuitConfirmation(ModalClass, Title, Message)— pushes a confirm modal,
binds the result, quits only on Confirmed. Quit button is a single node. Logs and does nothing if ModalClass is unset; it deliberately never quits unconfirmed.
USLModalWidgethardened —bIsModal, a navigation trap, aConfirmInputActionUI-action binding
(A confirms without navigating, shows in an action bar), and a pooled-reuse reset. See traps 4 and 5.
WBP_SL_Modal(the live dialog) andWBP_SL_CommandButton(aUSLButtonBasewhose face is a
glyph + label). Quit → confirm → desktop works, repeatedly, on pad and mouse.
- Docs:
Footguns.mdgained three UI entries;CommandBarBackButton.mdgot a dated correction block.
#⚠ Traps paid for this session — all written up in Docs/Footguns.md (UI section)
OnFinisheddoes NOT fire onGoToEndAndStop. OnlyFinishPlaybackInternalbroadcasts it, and
GoToEndAndStop bypasses that path — so a skip would park the camera and never show the menu. Hence the single ShowMainMenu event called from both places.
- A Slate input preprocessor in the default
Gamebucket loses to CommonUI's action router. Presented as
"every key skips except A". Register catchers as EInputPreProcessorType::PreGame.
- Stick deflection arrives as digital
ButtonAxiskeys, not analog events — rejectingFAnalogInputEvent
does nothing. Filter FKey::IsButtonAxis() (covers both sticks, both triggers, the mouse wheel).
- CommonUI's
bIsModaldoes not trap navigation — it scopes action routing only. Slate navigation is
governed by EUINavigationRule boundary rules; the Menu and Modal stacks are siblings in one Overlay, so a controller walked straight out of the dialog. Fixed with Stop rules on the modal root.
- The activatable stack POOLS widgets by class (
GeneratedWidgetsPool.GetOrCreateInstance). The same
modal object comes back on the second push with its one-shot state intact, so the dialog answered exactly once per session. Reset bResultBroadcast on activate; clear OnModalResult in PushModal (not on activate — callers bind after the push returns, and deferred activation would wipe the binding).
TriggeringInputActionon a shared button class is a trap, on a single instance it's correct. On
WBP_SL_Button's class default it gave every menu button a competing Confirm binding and disabled USLButtonBase's focus-glyph path (TryShowDefaultClickActionGlyph short-circuits on bHasOwnAction). On the modal's two button instances it's the right call — it routes glyphs through the legacy DataTable branch, which is the only path that reads the row's authored OverrideBrush.
#▶ NEXT
- Wire the remaining three menu buttons. Quit is done. Host →
HostMatch("/Game/SystemLink/Levels/Level1/Level1", ServerName, 8) — ⚠ on Success do nothing, the service broadcasts and then calls ServerTravel. Join → WBP_SL_JoinGame. Settings → push WBP_SL_Settings.
WBP_SL_JoinGame— direct IP first, server list second.
- Two-machine LAN test. PIE won't exercise Null's discovery beacon meaningfully.
#Loose ends worth 10 minutes each
bLogInputis still ON inWBP_SL_IntroSkipdefaults — one log line per press. Turn it off.
- Mouse click-through on the modal is unverified. The navigation trap covers controller/keyboard only;
clicks need a full-screen hit-testable (Visible) scrim in WBP_SL_Modal. Test by clicking where a main-menu button sits while the dialog is up.
WBP_SL_QuitConfirmis committed but unreferenced — the first attempt, superseded byWBP_SL_Modal.
Confirmed zero referencers; delete it when convenient.
- The command bar contradicts itself.
DefaultGame.iniregistersCD_SL_KBMand records that the
gamepad-only decision was reversed, but WBP_SL_CommandBar still runs the KBM-collapse graph. Pick one. → Docs/CommandBarBackButton.md, update block at the top.
CancelButtonmay show Ⓐ instead of Ⓑ if its instanceTriggeringInputActionisn't set to theBack
row — the focus fallback pushes the click action regardless of which button it is.
#Previous resume note (2026-08-26) — Multiplayer Phase 1: LAN service in, main menu boots
Branch mp-phase1-lan. Build clean. The ADS chapter below is closed history — this is the live work.
#✅ Shipped
USLSessionService+USLSessionService_LAN(7b2adca6) — the hosting seam from
MenusAndOnline.md §7, over OnlineSubsystemNull. Menus call HostMatch / FindMatches / JoinMatch / JoinMatchByAddress / LeaveMatch and never touch an OnlineSubsystem type, so Phase 6 registers an EOS subclass and no screen changes. MaxPlayers = 8 on ASLGameModeBase; Level1 went from 2 → 8 PlayerStarts.
- The menu map now builds a UI layout.
BP_SL_MenuHUD(parented toASLPlayerHUD, `LayoutClass =
WBP_SL_PrimaryGameLayout, HUDWidgetClass empty) is BP_MainMenuGameMode's HUD Class, and its OnHUDInitialized pushes WBP_SL_MainMenu to the Menu layer. Before this the map ran a stock AHUD, so there was no USLPrimaryGameLayout` and every push returned null.
ASLPlayerHUD::BeginPlay—HUDWidgetClassis now optional. It used to treat unset as a failure
(Error log + OnHUDInitialized(false)), which would have made the menu push off a false branch forever. An empty USLHUDWidget subclass is not an alternative: it has five required BindWidgets and won't compile.
WBP_SL_MainMenuexists, parented toUSLScreenWidget(notWBP_SL_Page— its chrome isn't wanted
here), fades in and lands focus on the Host button.
GameDefaultMap/EditorStartupMap→ MainMenu. Standalone now boots into the front end.
#⚠ Four traps, all paid for once — don't rediscover them
bPauseGameWhileActiveon a front-end screen freezes the level sequence.WBP_SL_Pagecarries
True (it was duplicated out of WBP_SL_Pause). A main menu inheriting that pauses the world on activation, so the Level Blueprint's Delay 6.0 never completes and LS_MainIntro never plays — it presents as a broken sequence, not a paused game. Parenting to USLScreenWidget avoids it (C++ default is false). Do not fix it on WBP_SL_Page: WBP_SL_Settings inherits that True and needs it, because pushing Settings deactivates the pause menu underneath, which unpauses.
USLScreenWidgetfails the compile without aGet Desired Focus Targetgraph override.
ValidateCompiledWidgetTree enforces it. That's the guard doing its job, not the reparent breaking.
- Fade with Render Opacity, never Visibility (footgun 6.28b) — and a Collapsed/Hidden widget never ticks
its animation at all. Bind on Construct, play on On Activated, re-assert focus from the animation's finished delegate rather than a hardcoded Delay.
USLSessionService::Get(WorldContextObject)is the only way to reach the service.
Get Game Instance Subsystem resolves by exact class and the base is abstract — it always returns null.
#▶ NEXT
- Wire the four buttons. Host →
HostMatch("/Game/SystemLink/Levels/Level1/Level1", ServerName, 8);
Join → WBP_SL_JoinGame; Settings → push WBP_SL_Settings; Quit → confirm modal → Quit Game. ⚠ On host Success do nothing — the service broadcasts and then calls ServerTravel.
WBP_SL_JoinGame— direct IP first (works when broadcast is blocked, and is what makes a failed LAN
test debuggable), server list second. It inherits bPauseGameWhileActive too if built on WBP_SL_Page.
- Two-machine LAN test. PIE won't exercise Null's discovery beacon meaningfully.
Full spec, including the failure-text table and the controller-first checklist: Docs/MainMenuScreen.md.
#Session close (2026-08-27) — pushed, then a big disk cleanup
21db01ebis pushed toorigin/mp-phase1-lan(upstream now tracked). No PR opened yet.
Remember this repo merges through pull requests on the internal code host, never a local merge to main, and a push needs an interactive credential prompt.
- ⚠ THE NEXT BUILD IS COLD.
Intermediate/andPlugins/SystemLinkCore/Intermediate/were deleted, so
the editor will not launch until SystemLinkEditor is rebuilt — expect several minutes, not the three seconds a warm "Target is up to date" gives. Nothing is broken; there is just nothing compiled.
- ~13.8 GB freed. SystemLink went 23.94 → 12.35 GB:
git lfs prune --verify-remote (10,275 objects, .git/lfs 9.58 → 4.23 GB — this was the real hog, not .git/objects, which is only 523 MB), the merged claude/hardcore-curie worktree and branch (ccb4ab45, the primary-fire work, fully contained in main), Intermediate ×2, Saved/Crashes, Saved/EditorPerformance, the webcaches, and .vs. Another 2.2 GB came from the four other UE projects under C:\3D-DEV (Intermediate in all four; Binaries only from FourCoreFive, the one with a Source/ folder — a Blueprint-only project's Binaries would not come back). Kept on purpose: Saved/Autosaves (the UMG crash-recovery net), Content/Library — despite looking like marketplace junk, NiagaraExamples is referenced by 11 assets including all three weapon primary-fire cues and BP_SL_GameMode — and the engine templates under Program Files.
- Inkscape 1.4.4 installed at
C:\Program Files\Inkscape\bin\inkscape.exe(not on PATH). Useful for
the UI texture work — it runs headless, e.g. inkscape.exe --export-type=png -w 512 file.svg.
#Previous resume note (2026-08-25, later) — ADS WORK SHIPPED. Merged to main as PR #41.
ads-assault-rifle-shotgun is merged and done (1d1c613b, PR #41, commit 56bf8a59). The branch can be deleted. Build clean, 25/25 automation tests pass.
#✅ Closed this session — do not reopen these
- All three reticles (AR, shotgun, pistol) are wired and working in PIE. Beepers called the reticle work
done. §4 of the 2026-08-21 note below is history, not a TODO.
- Spread during fire reads correctly for the AR and pistol in game. The long-standing "2026-08-17 values
never PIE-tested" item is closed.
- AR tuning is final.
MaxSpread 75againstMaxFireBloom 100— firing opens the reticle wider than
sprinting — is intentional and signed off. Spread is additive and unclamped by design. Leave it.
- Shotgun fire bloom stays at
FireBloomPerShot = 0. Its firing reaction is a custom animation on the
reticle Blueprint, so bloom would double up on a reaction that already exists. This is a decision, not a gap — every earlier note calling it "untuned" was wrong.
- Shotgun
ADSFieldOfView = 91(1.07x, barely a zoom, while its viewmodel FOV moves 25°) is
intentional. Do not "fix" it toward the other two.
OnADSStateSeededremoved (2026-08-25). No reticle Blueprint ever implemented it — verified by grep over
Content/. ⚠ SeedAiming was kept and is load-bearing: it sets the flag IsAiming() returns, right before USLHUDWidget calls SetVisibility, whose synchronous OnVisibilityChanged broadcast is the only hook a cached reticle gets on swap-in. Removing that too would strand a returning reticle on its pre-swap aim state.
Docs/ADS.mdcorrected. ItsADSFieldOfViewtable was stale in every row; live values are now
recorded with their zoom multipliers, alongside a fire-bloom section explaining the shotgun's 0.
#Live ADS values (read off the saved assets 2026-08-25)
| AR | Shotgun | Pistol | |
|---|---|---|---|
ADSFieldOfView → zoom | 80 → 1.30x | 91 → 1.07x | 50.9 → 2.29x |
ADSFirstPersonFieldOfView | 80 | 70 | 60 |
MaxSpread | 75 | 50 | 20 |
| Fire bloom (per shot / max / decay) | 15 / 100 / 110 | off — animation instead | 10 / 20 / 20 |
| RPM / PostFireDelay | 600 / 0 | 0 / 0.8 | 400 / 0.2 |
⚠ The shotgun's RoundsPerMinute = 0 is guarded (SLGameplayAbility_Fire.cpp:138) — the 0.8s PostFireDelay paces it, giving ~1.25 shots/sec.
#❌ Anim_FireKick — DROPPED 2026-08-25, do not re-raise
The AR's firing reticle reaction is delivered by fire bloom (FSLWeaponSpreadSettings::FireBloomPerShot), and Beepers confirmed it achieves the wanted look. The planned Anim_FireKick UMG animation is cancelled — it was never authored and never will be.
Why the old objection no longer applies. Earlier notes warned that "reticle bloom will NOT substitute for the animation: FSLWeaponSpreadSettings is movement-only, so a standing player's reticle is completely static while firing." That was written before fire bloom existed; adding FireBloomPerShot was precisely the fix. The warning is obsolete.
Why bloom is the better mechanism here, not merely an acceptable one: it sidesteps the retrigger trap that made the animation hard in the first place. PlayAnimation restarts from frame 0 on every call, so on a 600 RPM AR a kick would restart every 0.100 s and read as frozen at its opening frame. Bloom is a continuous accumulator with decay, so sustained fire builds and settles instead of stuttering, and it is frame-rate independent.
⚠ Do NOT remove USLReticle::OnWeaponFired() as dead code. It looks orphaned now that the AR kick is cancelled, but the Shotgun reticle binds it to drive its own custom firing animation. It was already dead once (declared with no dispatcher) and was wired up in d79701a3; do not let that regress.
#▶ NEXT
Nothing is outstanding on ADS, reticles, or the fire reaction — the whole chapter is closed. Pick the next feature.
ℹ Screenshots: Beepers drops Blueprint screenshots in Docs/Screenshots/ (tracked copies live in Docs/Screenshots/Site/). Check it when a screenshot is mentioned — it is not scanned automatically.
ℹ Workflow: this repo merges via pull requests on the internal code host, never a local merge to main. Push the branch, open the PR, and merge there. Pushes need an interactive credential prompt; if a push fails with "Authentication failed", the user must run it themselves.
#Previous resume note (2026-08-25, earlier) — reticles done, zoom readout added, then UNCOMMITTED
Branch ads-assault-rifle-shotgun. Last commit is still d79701a3. Builds clean, 25/25 automation tests pass. The pile is now 21 modified files + 3 untracked test files.
#✅ Reticles — CALLED DONE by Beepers, 2026-08-25
All three reticles (AR, Shotgun, Pistol) are wired and working in PIE. §4 of the 2026-08-21 note below is closed — read it for the pattern, not for the TODO list. The Pistol's AdsTick is a function (not just an event) driving Is Aiming → Branch → Hide Ads on False, with Tick Zoom Readout after the merge so the readout keeps counting back down on release.
⚠ Still open from §4 even though the reticles are done — these are DATA, not Blueprint:
- Pistol
MaxSpreadmay still be the struct default 5.0 next to the AR's 200. Verify.
ADSFirstPersonFieldOfViewonDA_ShotgunandDA_SL_Pistolmay still be 0 (frozen viewmodel). The AR
ships 80.
- Fire bloom on both may still be off (
FireBloomPerShot = 0). Do not copy the AR's numbers — they assume
600 RPM sustained. Read each weapon's RoundsPerMinute / PostFireDelay first.
#✅ Zoom multiplier + readout — NEW 2026-08-24/25, built, tested
A scope-label zoom number ("1.56x"), because nothing exposed one.
| File | Change |
|---|---|
Public/+Private/Character/SLPlayerCharacter.* | CalculateZoomMultiplier (pure static), GetADSZoomMultiplier (held weapon's full zoom), GetCurrentZoomMultiplier (live, off the camera's FOV). GetADSAlpha exposed to BP — it was C++-only. |
Public/+Private/UI/SLReticle.* | GetWeaponZoomMultiplier (one node, no cast), TickZoomReadout(DeltaTime, Duration) — a count-up 0 → full zoom on its OWN duration, GetZoomReadoutAlpha, and the pure static AdvanceReadoutAlpha. |
Private/Tests/SLZoomMultiplierTest.cpp | NEW, untracked — SystemLink.Character.ZoomMultiplier |
Private/Tests/SLZoomReadoutTest.cpp | NEW, untracked — SystemLink.UI.ZoomReadout |
The thing not to re-derive: magnification is the ratio of the half-angle TANGENTS, not of the angles. 95 → 70 is 1.56x; a naive 95/70 says 1.36x and is wrong. Halo CE's AR prints 1.4x, which ADSFieldOfView = 76 would match exactly (1.3968).
TickZoomReadout takes its own duration rather than riding ADSTransitionInDuration on purpose: that transition is 0.15s, right for the camera and far too fast for a number a player is meant to read. 0.35s is the authored default. It uses FInterpConstantTo so it lands exactly on the weapon's multiplier instead of creeping at it and printing 1.55x forever.
⚠ The multiplier is measured against the player's base FOV, so a player on 110 sees a bigger number than one on 95. That is the honest zoom. Halo prints a fixed number instead; matching that means calling CalculateZoomMultiplier with a hardcoded reference FOV.
ℹ BP wiring for the label: Tick Zoom Readout → To Text (Float) with Min AND Max Fractional Digits both set to 2 (they are AdvancedDisplay pins — collapsed behind the ▼, and the defaults 0/3 give "1.559" and trim "2.00" to "2") → Format Text {Zoom}x → SetText.
ℹ Perf, checked in the engine source, not assumed: calling this every tick is fine. UTextBlock::SetText looks unguarded but forwards to TSlateAttribute::Set, which gates invalidation on IdenticalTo (SlateAttributeBase.inl:262; FText uses a DeepCompare predicate) — so an unchanged string causes no layout or paint invalidation. What does cost every frame is building the FText before that guard sees it (a few small allocations from FastDecimalFormat + FText::Format). Negligible for one widget; guard on a Last Zoom float if you ever have several.
#✅ The inverted ADSFirstPersonFieldOfView docs — FIXED 2026-08-25
All three wrong places corrected (the old note said two — TickADS carried the same inverted claim): SLWeaponDataAsset.h, SLPlayerCharacter.cpp's TickADS, and Docs/ADS.md. Below the resting 95 the world zooms past a receding viewmodel (the AR's 80); above it the viewmodel magnifies. ADS.md also lost its stale "FirstPersonFieldOfView is hardcoded to 95" limitation and gained the zoom-multiplier note.
#▶ NEXT
- Commit. 21 modified + 3 untracked tests, nothing since
d79701a3, all green. This is overdue.
- Verify the three data gaps listed under the reticle section above (pistol
MaxSpread, both
ADSFirstPersonFieldOfView, fire bloom on shotgun + pistol).
- Decide on
OnADSStateSeeded— still no BP implementation anywhere. Implement it or strip it from
USLReticle along with SeedAiming.
Anim_FireKickinWBP_SL_Reticle_AssaultRifleis still unauthored (Event On Weapon Fired → Play
Animation, ≤ 0.09 s for the AR's 600 RPM).
- PIE-test the 2026-08-17 ADS spread values — still never verified.
ℹ Docs/Screenshots/ is where Beepers drops BP screenshots. Check it for recent files when a screenshot is mentioned — it is not scanned automatically.
#Previous resume note (2026-08-21) — ADS viewmodel FOV + reticle fire bloom, both built, both UNCOMMITTED
Branch ads-assault-rifle-shotgun. Last commit d79701a3 (pushed to origin) covered the reticle ADS state, the OnWeaponFired dispatch and the IsAiming capability check. Everything below is on top of that and not committed yet. Builds clean, 23/23 automation tests pass.
#1. ADS viewmodel FOV — DONE, tuned, works
The FP viewmodel renders at its own FOV so ADS cannot balloon it (Docs/ADS.md). That made it frozen during ADS, which read as too static. It is now movable per weapon.
| File | Change |
|---|---|
Public/Weapons/Data/SLWeaponDataAsset.h | new ADSFirstPersonFieldOfView (default 0 = leave alone) |
Public/Character/SLPlayerCharacter.h | new FirstPersonBaseFieldOfView = 95 member (was a hardcoded 95 in the ctor) |
Private/Character/SLPlayerCharacter.cpp | TickADS lerps the viewmodel FOV on the same ADSFOVAlpha and the same retained Data as the world FOV; same one-shot restore at the hip |
✅ DA_AssaultRifle.ADSFirstPersonFieldOfView = 80 — Beepers tuned and confirmed this looks right.
⚠ The direction is the opposite of what the header comment says. The comment claims < 95 magnifies and > 95 recedes. Beepers tested 100 first and it went the wrong way; 80 gives the wanted "zoom past it" look, so in practice lower = the world zooms past a receding viewmodel. The comment in SLWeaponDataAsset.h and the note in Docs/ADS.md are still wrong and need correcting — do not trust them, trust the 80.
Also fixed while in there: the stale comment in ApplyFPWeaponObstructionScale that claimed "the whole viewmodel magnifies uniformly with the zoom". Untrue since 2026-07-06 (7fc13096 added bEnableFirstPersonFieldOfView, one day after 44207bb5 wrote that comment).
ℹ Optional, not applied: Halo CE's AR zoom is 1.40x (printed on its reticle). Ours is 95→70 = 1.56x. ADSFieldOfView = 76 would match Halo exactly. Beepers said the world zoom is fine, so it was left at 70.
#2. Reticle fire bloom — BUILT, needs one tuning value
The reticle bloom was movement-only, so a standing player's crosshair was completely static while firing.
| File | Change |
|---|---|
Public/Weapons/Data/SLWeaponSpreadSettings.h | FireBloomPerShot (0 = off, default), MaxFireBloom (5), FireBloomDecayPerSecond (10) |
Public/+Private/Weapons/SLWeaponsComponent.* | pure statics AddFireBloom / DecayFireBloom beside CalculateSpread |
Public/+Private/Player/SLPlayerPerceptionComponent.* | FireBloom state + GetActiveSpreadSettings(); NotifyWeaponFired adds, TickComponent decays, UpdateReticleState sends movement + fire |
Private/Tests/SLFireBloomTest.cpp | NEW FILE, untracked — SystemLink.Weapons.FireBloom |
Decay runs every frame, above the 30 Hz throttle on purpose: decaying on the throttle beat would tie the bleed-off rate to sl.AimTarget.Rate, so changing that cvar would silently retune every weapon. Reticle Blueprints need no change — they already consume one spread float via OnSpreadChanged.
✅ RESOLVED 2026-08-21 — DA_AssaultRifle fire bloom is tuned, saved and confirmed by Beepers.
| Property | Value |
|---|---|
MinSpread | 0.0 |
MaxSpread | 200.0 |
MaxSpeed | 600.0 |
FireBloomPerShot | 15.0 |
MaxFireBloom | 100.0 |
FireBloomDecayPerSecond | 110.0 |
At 600 RPM influx is 15 × 10 = 150/sec against 110/sec of decay — net +40/sec, so it caps in 2.5 s and recovers in ~0.9 s. Bloom at the cap is 100 against a MaxSpread of 200, i.e. firing opens the reticle to half of what a full sprint does.
⚠ **An earlier draft of this note told you to set FireBloomDecayPerSecond = 25. That is STALE — ignore
it.** It was written against the old MaxSpread of 50 and before the rescale to 200. The saved asset is
the authority; these six values were read back out of DA_AssaultRifle.uasset after saving.
The rule (still correct, and the reason the old values looked dead): `FireBloomPerShot ×
shots-per-second MUST exceed FireBloomDecayPerSecond` or nothing ever accumulates. Time to cap =
Max ÷ (influx − decay). Recovery =Max ÷ decay.
⚠ Cosmetic only — it does NOT widen the bullet cone. That is FSLWeaponFireMode and a separate decision; the crosshair now overstates real inaccuracy while firing. Shotgun and pistol are untuned (PerShot still 0). ⚠ Watch the shotgun: single-shot with a 0.8 s post-fire delay, so one 5.0 spike can live and die between two 30 Hz samples. If it flickers, the fix is to broadcast spread immediately from NotifyWeaponFired (code, one rebuild).
#3. Reticle Blueprint — Beepers' work, settled
WBP_SL_Reticle_AssaultRifle drives ADS three ways, and the Event Tick poll is deliberate — keep it. It reads SLReticle::IsAiming() each frame and is the only approach that survived every swap case. Confirmed no meaningful perf concern: it does not tick while collapsed or hidden, and every Slate setter it reaches (SetRenderOpacity, SetSize, SetUserSpecifiedScale) is change-guarded internally, so calling Hide() on an already-hidden reticle queues no layout or paint invalidation.
Event Construct/Destruct→ bind/unbindOn Visibility Changed→InitializeVisibility(AdsReticle, IsAiming())
Event OnADSChanged→AdsStateChange
Event Tick→ Branch(IsAiming) → False →Hide
- A
Delay Until Next Tickwas tried in the visibility path and removed — it parks forever on a collapsed
widget (latent actions only pump from UUserWidget::NativeTick) and DelayUntilNextTick silently discards a re-trigger while one is pending (KismetSystemLibrary.cpp:2619). Do not reintroduce it.
- ⚠
WBP_ADS_Reticle's animations were renamedShow/Hide→ShowAnimation/HideAnimationto free
those names for functions. Hide() is the instant setter, AnimateHide() plays the animation.
#4. Shotgun + Pistol ADS — NOT STARTED, scoped 2026-08-21
The AR is the only weapon with the full ADS treatment. The other two zoom the world and nothing else. Gap below was read off the saved .uassets, not assumed:
| Piece | AR | Shotgun | Pistol |
|---|---|---|---|
ADSFieldOfView (world zoom) | set | set | set |
ADSFirstPersonFieldOfView (viewmodel) | 80 | 🔴 0 — frozen | 🔴 0 — frozen |
ADSTransitionIn/OutDuration | default .15/.11 | default | default |
Reticle embeds WBP_ADS_Reticle | yes | 🔴 no | 🔴 no |
Reticle OnADSChanged + IsAiming tick poll | yes | 🔴 no | 🔴 no |
OnWeaponFired bound in reticle | yes | yes | 🔴 no |
MaxSpread tuned | 200 | set | 🔴 default 5.0 |
| Fire bloom | tuned | 🔴 off (PerShot 0) | 🔴 off (PerShot 0) |
⚠ The table says set vs. default, not the actual number — it comes from which property names are
present in each asset's name table, and UE only serializes properties that differ from the C++ default.
Read the real values in the editor before tuning against them.
The work, in dependency order:
- Viewmodel FOV per weapon — data only, no rebuild. Set
ADSFirstPersonFieldOfViewonDA_Shotgun
and DA_SL_Pistol. Remember the direction is inverted vs. the header comment: the AR's 80 is what produces the wanted "world zooms past a receding viewmodel". Start both there and tune by feel — it is a per-silhouette judgement, and a shotgun's bulk may want a different number than a pistol's.
- Pistol
MaxSpread— still the 5.0 struct default, which is almost certainly not intended next to
the AR's 200. Set it before touching pistol fire bloom, since bloom is measured against it.
- Fire bloom for both — data only. Apply the §2 rule per weapon:
FireBloomPerShot × shots-per-second
must exceed FireBloomDecayPerSecond. Do not copy the AR numbers — they assume 600 RPM sustained fire. Read each weapon's RoundsPerMinute and PostFireDelay off its FSLWeaponFireMode first; a single-shot weapon has an influx near zero and needs a far gentler decay to accumulate anything. ⚠ Shotgun flicker risk: with a long post-fire delay one spike can be born and die between two 30 Hz reticle samples. If it flickers, the fix is to broadcast spread immediately from NotifyWeaponFired (code, one rebuild) — not to inflate the numbers until it happens to catch.
- ADS reticles — the real work, Blueprint-side, and the reason this is not a five-minute job. Neither
WBP_SL_Reticle_Shotgun nor WBP_SL_Reticle_Pistol has an ADS reticle at all. Copy the AR's proven pattern rather than reinventing it (§3 above, and it is render-opacity only — never Visibility): embed WBP_ADS_Reticle as a child, Event Construct/Destruct → bind/unbind On Visibility Changed → InitializeVisibility, Event OnADSChanged → AdsStateChange, and the deliberate Event Tick → Branch(IsAiming) → False → Hide. Do not reintroduce Delay Until Next Tick.
- Bind
OnWeaponFiredin the Pistol reticle — the Shotgun already has it; the Pistol does not.
- PIE-test all three weapons together. Swapping between them is what exposed every ADS state bug so
far — a reticle that is correct in isolation can still come back stale after a swap.
ℹ No C++ is expected for any of this. Everything above is data or Blueprint, unless the shotgun flicker in step 3 turns out to be real.
#▶ NEXT
- ✅
Set— done and saved 2026-08-21, see §2 above.DA_AssaultRiflefire bloom
- Bring the Shotgun and Pistol up to the AR's ADS feature set — see §4 above. This is the main
outstanding chunk of work.
- Fix the two wrong docs — the
ADSFirstPersonFieldOfViewdirection inSLWeaponDataAsset.hand the
viewmodel section of Docs/ADS.md. 80 is correct; the prose is not.
- Commit the C++ above +
SLFireBloomTest.cpp(untracked) + the tuned data assets.
- Still unauthored:
Anim_FireKickinWBP_SL_Reticle_AssaultRifle(Event On Weapon Fired → Play
Animation, ≤ 0.09 s for the AR's 600 RPM). OnADSStateSeeded also has no BP implementation — decide whether to implement it or have it stripped from USLReticle along with SeedAiming.
- PIE-test the 2026-08-17 ADS spread values — still never verified.
#⚠ Loose ends
- 🔴 Uncommitted: 8 C++ files,
SLFireBloomTest.cpp(untracked), and 5.uassets including
BP_WeaponActor_AssaultRifle, WBP_SL_Reticle_Shotgun and Shotgun2_FP_Fire (Beepers' edits, unreviewed).
dev/andtmp/stay untracked —dev/null/is git hooks from a> /dev/nullredirect on Windows,
tmp/ is CPU RMA screenshots.
- Repo-local git identity was set (there was none):
Mike Stults <mike.stults@gmail.com>.
- Permissions:
.claude/settings.local.jsondefaultMode=bypassPermissions;.claude/settings.json
holds the shared allowlist. The whole .claude/ dir is gitignored. Measured cause of the prompt storm: 43% of PowerShell calls started with $var =, ( or try {, which can never prefix-match a rule. Lead every PowerShell command with a cmdlet; prefer Bash for anything chained.
#Previous resume note (2026-08-20, later) — reticle ADS state + fire event wired; 2 of 4 NEXT items closed
Branch ads-assault-rifle-shotgun. The C++ from earlier today is unchanged and still compiled clean (plugin DLL 09:18 vs sources 09:16 — no rebuild needed). This later pass opened the editor and did the asset-side work over the systemlink-unreal MCP bridge.
#C++ (from the earlier pass, unchanged, still un-PIE-tested)
- Stale ADS state on cached reticles.
ActiveReticle->SetAiming(false)added above the existing
SetVisibility(Collapsed) in both SwapReticle (~:174) and ClearReticle (~:212) in Private/UI/SLHUDWidget.cpp.
USLReticle::OnWeaponFired()dispatched — was declared with zero callers. Path:
SLGameplayAbility_Fire::DispatchLocalCosmetics → USLPlayerPerceptionComponent::NotifyWeaponFired() → OnWeaponFired broadcast → USLHUDWidget::HandleWeaponFired() → ActiveReticle->OnWeaponFired(). No params, no throttle, no change-guard, deliberately absent from ApplyReticleStateToReticle, dispatched from the locally-controlled cosmetics path and not from FireCueTag. ⚠ ASLWeaponActor::OnWeaponFired(MuzzleLoc, FireDir) is a different function on a different class.
#✅ DONE this pass — how the AR's ADS reticle actually works (verified, not inferred)
Read off the live assets over the bridge. Worth not re-deriving — it contradicts the earlier plan.
WBP_SL_Reticle_AssaultRifle's widget tree isCenter_Ring,Tick_Top/Bottom/Left/Right, and the
embedded child AdsReticle (a WBP_ADS_Reticle_C, native parent UMG.UserWidget).
- 🔴 Neither Blueprint ever touches Visibility. The string
Visibilityappears zero times in
WBP_SL_Reticle_AssaultRifle.uasset or WBP_ADS_Reticle.uasset. Show/hide is 100% render-opacity.
WBP_ADS_Reticleowns two widget animations, and the AR graph plays them on the child:
| Animation | Binding | Track | Keys | Length |
|---|---|---|---|---|
Show | the ADS widget itself (WBP_ADS_Reticle_C_5) | RenderOpacity | f0→0.0, f7→1.0 | 7 f @ 20 fps = 0.35 s |
Hide | same | RenderOpacity | f0→1.0, f5→0.0 | 5 f @ 20 fps = 0.25 s |
- So NEXT item 3 as previously written was wrong and would have broken ADS permanently. Setting the
child's designer Visibility to Collapsed could never be undone — nothing anywhere sets Visibility back.
Item 3 — FIXED, correctly: AdsReticle.RenderOpacity 1.0 → 0.0 in the AR reticle's Designer (Visibility deliberately left at SelfHitTestInvisible). It was starting fully opaque, so a freshly created AR reticle showed the ADS ticks during hip-fire until the first aim→release played Hide. Show animates that exact property straight back to 1.0, so the fix is fully reversible. Compiled + saved.
Item 2 — CONFIRMED. The AR event graph references both the Show and the Hide animation names plus PlayAnimation and a bIsAiming event node, and Hide ends at RenderOpacity 0. So OnADSChanged(false) does act. The C++ guarantees the event arrives; the graph does hide the child.
Also fixed: DA_Unarmed.PrimaryReticleClass WBP_SL_Reticle_Shotgun_C → WBP_SL_Reticle_Default_C. ⚠ None is not a safe value here: USLHUDWidget::SwapReticle early-returns on a null class rather than clearing, so unarmed-with-None would strand the previous weapon's reticle on screen. Default is the fix.
#✅ DONE this pass — the cached-reticle ADS desync, properly this time (C++, REBUILT 46 s)
The earlier SetAiming(false)-before-collapse fix could not work, and has been replaced.
Why it could not work. UUMGSequenceTickManager::TickWidgetAnimations (UMGSequenceTickManager.cpp:286) gates on UserWidget->IsVisible(), and UWidget::IsVisible() is GetVisibility().IsVisible() — both Collapsed AND Hidden fail it (neither has the VIS_Visible bit). The world tick runs before Slate pre-tick, so SetAiming(false) → OnADSChanged(false) → BP starts Hide, then SetVisibility(Collapsed) lands on the same frame and the animation is frozen at frame 0 — whose own first key is RenderOpacity = 1.0 — then torn down by DisableAnimations(). The reticle went into the cache with the flag saying "not aiming" and the ADS overlay fully opaque. Bringing it back while not aiming hit the change-guard in SetAiming, so nothing ever corrected it: 🔴 ADS ticks visible during hip-fire, for the rest of the match. (Bringing it back while aiming self-corrected with a flicker.)
The fix — snap on the way IN, clear nothing on the way out.
| File | Change |
|---|---|
Public/UI/SLReticle.h | new OnADSStateSeeded(bool) BlueprintImplementableEvent + SeedAiming(bool) |
Private/UI/SLReticle.cpp | SeedAiming — sets bADSActive and fires OnADSStateSeeded unconditionally |
Private/UI/SLHUDWidget.cpp | ApplyReticleStateToReticle: SetVisibility moved FIRST, then SeedAiming |
Private/UI/SLHUDWidget.cpp | SwapReticle + ClearReticle: the two SetAiming(false) calls removed |
Private/UI/SLHUDWidget.cpp | HandleReticleHiddenChanged: re-seeds on the hidden→visible edge (same defect) |
Public/UI/SLHUDWidget.h | stale ClearReticle comment fixed — it has not cleared ReticleContainer since BUG-027 |
⚠ Ordering in ApplyReticleStateToReticle is load-bearing, not cosmetic. Seeding before the widget is visible reintroduces the whole bug.
⚠ The same trap applies to the fire-kick animation below: a shot that lands while the reticle is Hidden starts an animation that never ticks.
Audited and found fine: un-collapsing works (ApplyReticleStateToReticle runs at the end of every swap); the wiring lives in WBP_SL_HUDWidget (SwapReticle, ClearReticle, GetActiveReticleClass, OnSidearmActiveChanged) and not in BP_SL_PlayerHUD; both C++ functions have zero C++ callers.
#▶ NEXT — all of it needs a human at the editor
- Author the fire reaction animation — cannot be scripted:
WidgetBlueprint.Animations is read-protected in Python and K2 node wiring is not exposed at all. In WBP_SL_Reticle_AssaultRifle: Animations → new Anim_FireKick (scale punch / opacity flash on
Center_Ring + the four ticks), then Graph → Event On Weapon Fired (category `SystemLink | UI`) → |
|---|
Play Animation, Num Loops To Play = 1.
⚠ Length budget, measured off the live fire modes — PlayAnimation restarts from frame 0 each call, so anything longer than the shot interval never reaches its end and reads as frozen:
| Weapon | Mode | Gate | Budget |
|---|---|---|---|
| AR | Full-auto, 600 RPM | 0.100 s between shots | keep ≤ 0.09 s |
| Pistol | Single, 400 RPM + 0.2 s post-fire delay | ~0.2 s | ≤ 0.15 s |
| Shotgun | Single, 0.8 s post-fire delay | 0.8 s | ≤ 0.6 s |
The existing Show/Hide use a 20 fps display rate; at 0.09 s that is under 2 frames, so author Anim_FireKick at 60 fps (≈5 frames) instead of following that convention. The event is on the USLReticle base, so pistol, shotgun and default reticles can implement it too.
2. Wire Event On ADS State Seeded in WBP_SL_Reticle_AssaultRifle (category `SystemLink | UI`) — |
|---|
the C++ half of the desync fix is built, but the event has no implementation yet, so nothing snaps. Two nodes: Event On ADS State Seeded → Set Render Opacity, Target AdsReticle, In Opacity = Is Aiming ? 1.0 : 0.0 (a Select on the bool). Do not play Show/Hide here — the whole point is that it snaps rather than animates. OnADSChanged keeps playing them for live transitions.
- PIE-test everything on this branch — none of it has ever been seen running. The 2026-08-17 ADS spread
values (fire at a wall from a fixed distance, hip vs aimed, AR and shotgun); the reticle no longer showing ADS ticks while hip-firing; unarmed showing the Default and not the shotgun crosshair.
#⚠ Loose ends
- 🔴 NOTHING IS COMMITTED. Working tree: the five C++ files,
DA_AssaultRifle,DA_Shotgun,
DA_Unarmed, WBP_SL_Reticle_AssaultRifle, Docs/ADS.md, Docs/CurrentFocus.md, .mcp.json, Tools/SystemLinkMCP/requirements.txt, and untracked dev/ and tmp/.
- ⚠ Only the AR embeds an ADS reticle. Pistol and shotgun aim with zero reticle feedback, despite the
shotgun having a working ADSSpreadMultiplier of 0.8. WBP_ADS_Reticle is reusable as-is — it is a plain UMG.UserWidget child with self-contained Show/Hide, so embedding it in the other two is a Designer drag plus the same two PlayAnimation calls.
- ⚠
WBP_ADS_Reticleis an embedded child, not a swap target. Its native parent isUMG.UserWidget,
not USLReticle, so it physically cannot be passed to SwapReticle. ADS restyles in place.
- ⚠ CLAUDE.md is stale: it claims weapon data holds "montages keyed by
ESLWeaponMontageType". That
enum has no definition anywhere — only a forward declaration at FSLWeaponViewDriverBase.h:5.
- ⚠ Bullet cone is client-side and never server-validated, and
bAimHeldis not replicated.
Pre-existing. Any future server-side cone validation needs the aim state replicated first.
- ℹ️ Bridge introspection limits hit this pass (so they are not re-discovered): on
WidgetTree,
RootWidget/AllWidgets are unreadable, and on WidgetBlueprint/WidgetBlueprintGeneratedClass Animations is read-protected; every K2Node property (FunctionReference, EventReference, …) and EdGraph.Nodes are protected. What does work: unreal.find_object(bp,'WidgetTree') then find_object(wt,'<WidgetName>') for the widgets, find_object(eg,'K2Node_<Class>_<N>') to enumerate graph nodes by brute force, find_object(generated_class,'<Anim>_INST') for animations, the unreal.MovieScene*Extensions scripting API to read their bindings/tracks/keys, and reading the raw .uasset FName table with a [ -~]{4,} regex to prove a function name is or is not referenced at all.
#Previous resume note (2026-08-17) — ADS spread tuned for AR + shotgun
Branch ads-assault-rifle-shotgun. The ADS mechanism was already complete and correct; the gap was data, not code — the AR had never been given an ADSSpreadMultiplier, so aiming it changed the FOV and the look sensitivity but left the cone at a flat 2.0°.
#What changed (two data assets, no C++)
| Asset | Property | Before | After |
|---|---|---|---|
DA_AssaultRifle | PrimaryFireMode.ADSSpreadMultiplier | 1.0 (no-op) | 0.5 → 2.0° hip, 1.0° aimed |
DA_Shotgun | PrimaryFireMode.MaxBulletConeAngleDegrees | 5.0 (inert) | 0.0 (explicitly off) |
DA_Shotgun | PrimaryFireMode.MaxBulletSpreadSpeed | 1.0 (clamp floor) | 600.0 (default) |
The shotgun's two edits are a behaviour-preserving cleanup, not a tuning change. Its max cone equalled its base cone, and CalculateConeAngle only lerps when max is strictly greater — so the movement branch never ran and the 1.0 speed was meaningless. They now read as deliberately off instead of half-set. The shotgun's actual ADS tightening (0.8 → 5.0° hip, 4.0° aimed) was already in place and is unchanged.
⚠ Neither value has been PIE-tested. Fire at a wall from a fixed distance, hip vs aimed, for both weapons.
#Reference facts worth not re-deriving
ADSSpreadMultiplieris per FIRE MODE, not per weapon —FSLWeaponFireMode, so primary and secondary
tune independently. Only primary is set on any weapon today.
- The multiplier applies AFTER the movement lerp. Doing it first would let aiming cancel the movement
penalty outright. Locked in by SystemLink.Weapons.ConeAngle.
- A multiplier on a zero base cone is still zero — the reason setting it on a pinpoint weapon looks broken.
- The AR has no movement spread at all (
MaxBulletConeAngleDegrees0). The pistol is the only weapon that
has it. Worth a deliberate decision rather than leaving it as an accident.
IsAiming()returnsbAimHeldraw, notGetADSAlpha()— so spread snaps on press while the zoom eases.
Deliberate (it is what Halo does); sensitivity took the opposite choice and lerps by alpha.
- ⚠ Cone is client-side and never server-validated, and
bAimHeldis not replicated. Pre-existing. Any
future server-side cone validation needs the aim state replicated first.
Full writeup: Docs/ADS.md — rewritten this session (its intro, aiming definition, "read the sidearm's own data" rule and "not built yet" list were all stale from the sidearm-only era).
#Previous resume note (2026-08-11) — ✅ Teleporters DONE; all three interactables working
Branch level1-interactables. Pickups, doors and teleporters are all built and working. Nothing on this branch is committed yet — see the loose ends below before starting anything new.
#Teleporters — done 2026-08-11
ASLTeleporter+USLScreenEffectWidgetwritten, built clean, and **PIE-tested by Beepers:
"they test well." Full reference: Docs/Teleporters.md**.
BP_SL_TeleporterandBP_SL_TeleporterColumnbuilt from the C++ base, a pair placed inTestMap, new
Niagara set authored (NS_SL_TeleporterField, NS_SL_SlipspaceArcs, NS_SL_TeleporterFilaments).
WB_Teleportreparented toUSLScreenEffectWidget, so the overlay owns its own lifetime — no
animation-length-vs-ArrivalOverlayDuration pair to keep in sync.
- Duplicate-asset cleanup done: the dead
Content/SystemLink/UI/HUD/Teleporter/copies ofWB_Teleport,
MI_TeleporterUI and M_TeleporterUI were deleted via the asset registry (referencers checked first, one delete per bridge call). The live copies are all under Environment/Teleporter/. The empty folders may still show in the Content Browser until the editor restarts.
⚠ Still worth one deliberate pass: teleport as a non-host CLIENT in 2-player PIE. That is the case the whole class exists for and the one that is invisible when the host is the server. Docs/Teleporters.md §8 lists the rest of the matrix.
#USLScreenEffectWidget — new, reusable
Public/UI/SLScreenEffectWidget.h. Base for transient, non-interactive full-screen effects shown to ONE local player. The widget plays its own animation and calls FinishEffect(); the base class owns removal, with a MaxLifetime backstop, because a Blueprint that forgot would weld an overlay to that player's HUD for the match. Not USLCommonActivatableWidget (that is the interactive/stack side) and not USLDamageOverlayWidget (that one is persistent and attribute-driven).
Next users: respawn wash, shield break, overshield pickup, damage flash. ASLTeleporter detects the base at runtime and falls back to a plain UUserWidget + timer, so adopting it elsewhere needs no C++ change.
#What the earlier part of this session did
- Analysed the migrated
BP_Teleporterfrom six BP screenshots (Docs/Screenshots/2026-08-10 0816–0817). - It teleported straight out of
OnComponentBeginOverlapwith no authority check — the client, not Enabled/Target Actorwere unreplicated but drove the rings and the hum.- A
Does Object Implement Interfacenode with an EMPTY interface gated the whole cosmetic - Its
Target Actorlatch was released only byEndOverlap, so **dying on a pad bricked the teleporter - Also:
Play Sound at Locationpinned to(0,0,0); arrival facing set via actor rotation, which does
Verdict: not safe for multiplayer, and it reproduced all three faults ASLDoor was written for.
the server, was deciding where a player ended up.
Sequence. Always false — which is exactly why the teleport worked but the sound, the ring hide and the HUD call never ran. That node was the real blocker on the HUD hookup, not missing wiring.
for the rest of the match**.
nothing to where a possessed player looks.
- Wrote
ASLTeleporter(Public/World/SLTeleporter.h+Private/World/SLTeleporter.cpp) — the same - Authority-only overlap; server does the move;
bReadyreplicated withOnRepfor the pad visuals. - Readiness is a timer, not a latch — there is no occupant state a dead player can strand.
ClientSetRotationfor arrival facing;ExitMomentumenum (defaulted toRedirect— an assumption,- The old
CanBeTeleportedTocount-the-overlaps graph is one server-side sphere query. ASLPlayerController::Client_OnTeleportedadded: a level actor has no owning connection, so its own
wrap-in-C++ shape as ASLDoor. Builds clean. NOT PIE-tested and no Blueprint uses it yet.
not Beepers' decision) plus a MaxExitSpeed clamp for the 1625 cm/s dash case.
Client_ RPCs go nowhere. The controller makes the hop and calls back into the teleporter.
Docs/Teleporters.mdwritten — full usage reference, and §8 lists the six 2-player PIE cases in order.
Docs/Footguns.md— seven new entries (overlap authority, unowned-actor RPCs,ClientSetRotation,
the before-the-move ordering rule, latch-vs-timer, counter-for-one-shot-cosmetics, empty migrated pins).
#NEXT: build BP_SL_Teleporter — DONE 2026-08-11, kept for the recipe
BP_SL_TeleporterA fresh subclass, not a reparent (reparenting kills the editor mid-operation). Everything structural is in C++, so the Blueprint is small — full steps in Docs/Teleporters.md §3:
- New BP from
SLTeleporter; meshes + rings Niagara + hum AudioComponent under the inheritedRoot.
- Resize the inherited
TriggerVolume— do not add your own, it would be bound to nothing.
- Move
ExitPoint; its +X is the arrival facing.
- Implement
OnTeleporterStateChanged(bIsReady, bImmediate)→ rings visibility =bIsReady,
audio paused = NOT bIsReady. Honour bImmediate (snap, don't animate).
- Class Defaults:
ArrivalOverlayClass=WB_Teleport,TeleportSound2D=HaloTeleporterTeleport_Cue.
That is the entire HUD hookup — C++ creates, shows and removes the overlay.
- Place two in Level 1 and point each instance's
TargetTeleporterat the other (EditInstanceOnly, so it
can only be set on placed instances).
⚠ Two WB_Teleport assets exist — Environment/Teleporter/ and UI/HUD/Teleporter/. Confirm which one you have been editing before assigning it.
#⚠ Loose ends carried into the next session
- 🔴 NOTHING ON THIS BRANCH SINCE
929766fbIS COMMITTED. All the teleporter C++, both teleporter
Blueprints, the three new Niagara systems, the reparented WB_Teleport, the door BP work (BP_ForerunnerDoor1 + untracked BP_ForerunnerDoor1Back), TestMap, Level1.umap, BP_SL_MasterChief, NS_ShieldBreak, dash-progress.uasset. Several are untracked .uasset binaries with no recovery path. Commit before starting anything else.
Docs/Doors.md§7 status is still unverified — the §4 property table is now complete (fixed
2026-08-11), but §7 claims ASLDoor is unused and un-PIE-tested. Five follow-up commits on 08-07 (switches eating shots, MaxOpenTime, the pending-close cancel) read as live-test findings, so the claim is probably wrong — but nobody has confirmed it. Update it from memory of that session, not from guesswork.
- Pickup placement status unconfirmed —
Level1.umapis modified but it is not clear whether that is
door placement, pickups, or both. Level 1 also still has the legacy BP_OverShieldPickup_C rather than the GAS BP_SL_OvershieldPickup.
- Level 1 vs TestMap: the teleporter pair went into
TestMap. Level 1 still needs its own placement.
#Previous resume note (2026-08-07) — 🎯 Level 1 interactables: pickups, doors, teleporters
Branch level1-interactables, off main at 99941455 (PR #35 merged level1-import).
The goal is a Level 1 you can actually play through. The GameMode blocker turned out to be already fixed, so what remains is world interaction.
#Inventory — what exists before starting
| System | C++ | Blueprints | State |
|---|---|---|---|
| Pickups | SLPickupBase, SLWeaponPickup, SLAmmoPickup, SLGrenadePickup, SLHealthPickup, SLOvershieldPickup | BP_SL_WeaponPickup_{AssaultRifle,Pistol,Shotgun2}, BP_AmmoPickup_AssaultRifle, BP_SL_GrenadePickup_Frag, BP_HealthPickup, BP_SL_OvershieldPickup | Complete. The work is PLACEMENT, not code. |
| Doors | none | BP_ForerunnerDoor1 + meshes/materials/HaloDoor1_Cue | Migrated from the 5.6 project, untested here |
| Teleporters | none | BP_Teleporter + NG_TeleporterRings/Sparks, sounds, WB_Teleport | Migrated, untested here |
#⚠ Level 1 has the LEGACY overshield pickup placed, not the GAS one
Confirmed live in PIE 2026-08-05: the two overshield actors in Level 1 are BP_OverShieldPickup_C (Content/SystemLink/Environment/OverShieldPickup/) — the migrated 5.6 asset — not BP_SL_OvershieldPickup (Content/SystemLink/Pickups/Overshield/), which is the one deriving from ASLOvershieldPickup and going through the GAS path. Swap them when placing. Expect the same trap for any other interactable carried over from the old project: two assets, similar names, only one wired to the current systems.
#The decision to make before writing door/teleporter code
Both are world state that every machine must agree on, and neither has a C++ base today. The old project's Blueprints were written for a different (largely non-GAS, likely single-player) setup, so "it worked before" is not evidence it works here.
- A door opened client-side is a desync: one player walks through what another sees closed.
- A teleporter is worse — moving a pawn is authoritative gameplay, not cosmetics. Client-side
teleport is both a desync and a trivial cheat vector.
So the likely shape is a small server-authoritative C++ base for each (ASLDoor, ASLTeleporter) with the visuals and timing left in Blueprint, matching how the pickups are built. Test the migrated Blueprints in 2-player PIE first — that tells us how much of them survives rather than guessing.
#Order of work
- Play Level 1 as-is. It may already be walkable; the only known gaps are the interactables.
- Place the GAS pickups (old coordinates recorded in
Docs/Level1Import.md§4), swapping the
legacy overshield for BP_SL_OvershieldPickup.
- Doors — test
BP_ForerunnerDoor1in 2-player PIE, then decide rebuild vs wrap.
- Teleporters — same, and treat authority as non-negotiable.
⚠ Watch for the level1-fort-floor paper floor while playing: zero-thickness complex collision, and a 1625 cm/s dash is exactly what punches through it. → Docs/CollisionMeshes.md §0.
#Previous resume note (2026-08-05) — 🎯 FOCUS: still "make both levels playable"
Branch level1-import. Goals below are unchanged; this session was view-mode and dash FX.
#What this session changed
- The
BP_SL_MasterChiefview-mode port is LIVE. The oldSetMeshVisibilitynode soup is gone and
the timeline calls Apply View Mode Visuals. The 2026-08-03 "half-finished" item is done.
- BUG-030 — other players' first-person arms rendered in the world, floating and disembodied, while
- Two faults compounding: FP hiding sat behind
if (!IsLocallyControlled()) return;, and the only - Fix: new
ASLPlayerCharacter::ApplyFirstPersonMeshVisibility(), called fromApplyViewModeVisuals - THE RULE:
SetOwnerNoSeeis evaluated per viewer;SetVisibilityis per machine.
their TP body rendered correctly at the same time. Surfaced because the port went live: the BP function had been masking it, and ApplyViewModeVisuals became the single point of truth. ✅ FIXED AND PIE-VERIFIED by Beepers 2026-08-05 — both halves: remote players no longer show FP arms, and the owner's own FP↔TP toggle still behaves.
call site was the view-mode timeline, which runs solely on the pawn's own client — so a remote pawn was never called at all.
above the guard, plus from BeginPlay / PossessedBy / OnRep_PlayerState (each above their own early returns). Full RCA: Docs/BugTracker.md → BUG-030.
Anything hidden with SetVisibility must be driven on every machine and can never sit behind IsLocallyControlled().
- BUG-031 — the "Press E to equip" prompt stuck on the HUD permanently, advertising a weapon that was
no longer in the world. Two independent holes, both fixed: the prompt was never retracted when a pickup was destroyed under a standing player (PendingPickup is a UPROPERTY, so UE nulls it silently and the state reads clean), and a hide issued at death reached nothing because GetHUDWidget() resolves through GetController(), which an unpossessed pawn no longer has. Fixed in ASLWeaponPickup::EndPlay, in InitializeLocalPlayerHUD (clears the prompt on every possession), and by having both prompt RPCs bail on a null HUD. PIE shutdown errors confirmed gone; the die-on-a-pickup path is still untested.
- BUG-032 — the overshield indicator showed for players with no overshield. ✅ PIE-verified fixed.
Not a state bug: live values were CurrentShield 50 / MaxShield 50 with no Overshield tag, so OnOvershieldChanged(0.0) was firing correctly and nothing was acting on it. Three defects in one area — a ProgressBar at 0% still draws its background brush; PrevOvershieldPercent was not reset per life, so the activate/deplete edges could fire spuriously or be missed entirely; and NativeConstruct (the listen-server host path) never called OnReset() while InitializeHealthWidget (the client path) always did.
- Controller rumble on fire — plumbing built, NOT wired to assets yet.
FSLWeaponFireMode::FireForceFeedback (per weapon and per fire mode, beside FireCueTag), a bControllerRumble player setting, and dispatch from DispatchLocalCosmetics — local-only by construction, and the setting is read per shot so a pause-menu toggle applies immediately. Still to do: author the UForceFeedbackEffect assets, assign them per weapon, and add the settings row. ⚠ Keep each effect SHORTER than that fire mode's shot interval or full-auto retriggers it into a continuous buzz.
- Dash thruster cue is DONE — FX and sound both in (Beepers). Reference facts below so the next
- The pack system was copied to
/Game/SystemLink/FX/Character/NS_Thrustersrather than edited in - Duration is a
User.Durationfloat user parameter, linked into the emitters' Emitter State - ⚠ 0.3 s FX vs 0.2 s dash.
DashDurationis not overridden inDA_SL_MobilityModule_MMA3, so - To make them track each other: add
CueParams.RawMagnitude = Module->DashDuration;in
session does not re-derive them the expensive way.
Content/Library/, so a pack update cannot clobber the tuning.
Loop Duration (Loop Behavior Once). ✅ Wired end to end 2026-08-05: CueParams.RawMagnitude = Module->DashDuration in SLGameplayAbility_Dash::ActivateAbility, read in the cue graph as Parameters → Raw Magnitude into Set Niagara Variable (Float). The FX now tracks whatever DashDuration the equipped mobility module specifies, so the two can no longer drift.
it is still the C++ default 0.2f (SLMobilityModuleDataAsset.h:41). The thrusters therefore burn 50% longer than the movement. Fine as a deliberate trailing flourish — but it was not chosen, and the two values will silently drift apart the first time either is retuned.
SLGameplayAbility_Dash::ActivateAbility next to the existing Location/Normal, then feed Parameters → Raw Magnitude into Set Niagara Variable (Float) "User.Duration" in the cue BP. ⚠ A one-shot emitter reads Loop Duration once at emitter spawn — so uncheck Auto Activate on the spawn node, set the variable, then Activate, or the first and only loop uses the default.
#▶ NEXT UP — the two items Beepers named 2026-08-05
1. Dash HUD indicator — C++ side BUILT, widget not started. USLMobilityComponent now exposes GetDashRechargeProgress() (0–1, BlueprintPure) and IsRechargingDash(), alongside the existing OnDashChargesChanged(Current, Max) delegate and GetDashCharges() / GetMaxDashCharges().
- Progress is derived, not replicated: the server replicates
RechargeWindowStartTime+
RechargeWindowDuration (two floats that change only on a dash or a charge landing) and each machine interpolates locally against GameState->GetServerWorldTimeSeconds(). Replicating the fraction itself would be per-frame traffic for a HUD bar. Never use GetWorld()->TimeSeconds here — it is per-machine and the client's bar would disagree with the server's timer.
- The window is closed on equip and unequip as well as at a full pool, so the bar cannot creep toward
full for a module that is gone.
MaxDashChargesis 1 and stays there (decided 2026-08-05).DA_SL_MobilityModule_MMA3was already
set to 1; the C++ default was 2 and is now 1 so future modules start there too. A dash is a committed decision, not a pooled resource. The indicator is therefore a single bar, not segments.
- Live values on the MMA-3:
MaxDashCharges 1,DashRechargeDelay 0.0,DashRechargeTime 1.25,
DashDuration 0.2, DashSpeed 1625. Because the delay is zero, the bar simply fills 0→1 over 1.25 s from the moment of the dash — the "dead beat vs immediate creep" question does not arise for this module. It would return if any module ever sets a non-zero delay.
- Still to do: the widget itself — empty the instant a dash is spent, fill over the recharge. Bind
OnDashChargesChanged for the full/empty state and poll GetDashRechargeProgress() for the fill.
2. ADS for AR + shotgun — NOT started. Two decisions first. The pistol keeps its current behaviour (input already remapped to left shoulder). Left trigger becomes normal ADS, which does not display the weapon mesh in FP, and the reticle must know so it can adjust.
- The blocker is one line.
SLPlayerCharacter.cpp:502reads
bAiming = WC && WC->IsSidearmActive() && Data && Data->ADSFieldOfView > 0.f — ADS is hardcoded to "sidearm is active", which is the pistol implementation. Everything downstream already generalises: ADSFieldOfView is per-weapon on USLWeaponDataAsset, and look sensitivity already scales by GetADSAlpha() (SLPlayerController.cpp:244).
- ⚠ The FP mesh hide must go through
ApplyFirstPersonMeshVisibility(), not a second writer. That
function is the single owner of FP visibility as of BUG-030. A separate ADS setter means toggling view mode while aiming un-hides the weapon — the exact class of bug just fixed. ADS should be a factor it reads.
- ⚠ Left trigger may be double-booked. The planned secondary-fire design is "LT held = mode, RT =
fire" (two-phase chord via GAS tags). If LT is now ADS, one of the two needs rebinding. Settle it before the input and reticle work, not after.
#Reference — RocketThrusterExhaustFX pack + the dash cue (learned 2026-08-05)
Derived by reading the compiled HLSL inside the .uasset; Niagara's Python API exposes none of this.
- ⚠ Most of the pack's User Parameters are decoys. Only these are actually consumed by the compiled
scripts: User.Emissive_Boost and the eight User.Particulate_*. **Every User.Thrusters_, User.Smoke_, User.EnergyCore_ and User.HeatHaze_ parameter exists in the parameter store and is read by nothing.** Setting them from Blueprint is a silent no-op. The other emitters' values are baked constants (Constants.NE_Thrusters.InitializeParticle.Lifetime, …EmitterState.Loop Duration).
- ✅ Scale already works via component scale.
NMS_GlobalScaleandNMS_GlobalVelocity(in every
emitter) read Engine.Owner.Scale, so Set Relative Scale 3D on the spawned component scales sprite size and velocity — i.e. the plume shortens as it narrows. This bypasses the user parameter store entirely, and it works regardless of local/world space. Per-emitter NMS_GlobalVelocity.VeloticyReduction (pack's typo) tunes how hard length follows scale.
- ⚠
GC_Mobility_Dashis aGameplayCueNotify_Burst(i.e._Static):OnExecuteonly, **no
OnRemove, and CDO-based with no per-instance state — so you cannot** stash the spawned NiagaraComponent and Deactivate it later. To make an FX stop with the ability you either make it self-terminating (Loop Behavior Once + Loop Duration) or move to a GameplayCueNotify_Looping actor cue driven by AddGameplayCue/RemoveGameplayCue.
- ⚠ The socket is
ThrusterLeftSocket/ThrusterRightSocketon
/Game/SystemLink/Characters/MasterChief/Meshes/MasterChief. The misspelling Thurster… fails silently — Spawn System Attached does not warn on an unknown socket, it just attaches at the component origin, so the FX plays from Chief's root instead of his back.
#Previous resume note (2026-08-03) — the content move
Branch level1-import. The tree was clean — everything below is committed.
#Then the two goals, unchanged
1. Level 1 playable. ✅ GameMode is DONE — verified 2026-08-06, World Settings points at /Game/SystemLink/Game/BP_SL_GameMode, which exists. (The old note here said it still pointed at a missing BP_SystemLinkGameMode; that was stale.) Collision is already done, author none (details in the 2026-07-31 note below, including the level1-fort-floor paper-floor risk), and 2 PlayerStarts are placed. So Level 1 may already be playable — nobody has actually run it. Play it before planning anything else here; the only known gap is that the old weapon pickups were removed and GAS pickups have not been re-placed (coordinates recorded in Docs/Level1Import.md §4).
2. MainMenu showing a real menu, built on the CommonUI stack over the existing diorama.
3. Then cleanup — the 207-asset delete list in Docs/Level1Import.md §3/§8. Nothing deleted yet.
#What this session changed
- Committed the interrupted content reorganization —
Content/{Environment,Levels,LevelPrototyping}
→ Content/SystemLink/…, 226 assets moved, 179 deletions. It had been sitting uncommitted on disk. Before committing, every one of the 48 files left at the old paths was checked and confirmed to be an ObjectRedirector stub — no real asset was stranded, and the levels are intact at the new paths (Level1 452 KB, MainMenu 2 MB). The redirectors are committed deliberately so old references still resolve; fixing them up is a separate pass and needs the editor.
- Committed the hard-light bridge scripts, doc and source textures (the materials/meshes themselves
rode along with the move), the RocketThrusterExhaustFX pack, BP_GA_EquipDefaultLoadout, the new site screenshots, and the devlog's Halo military-calendar timestamps.
#Machine stability — resolved enough to stop worrying about
Turbo Boost off (2026-07-25) is what stopped the BSODs. Confirmed 2026-08-03: zero unexpected shutdowns in the 9 days since, against 14 in the 14 days before. That confirms the diagnosis — degraded silicon failing at high boost — but it is a workaround, not a repair, and the RMA case is now stronger, not weaker. Do not re-enable Turbo to speed up builds. Full detail in project memory.
#Previous resume note (2026-07-31, end of night) — make both levels playable
Branch level1-import (off mobility-cues, which is still unmerged — 15 commits ahead of main, a clean fast-forward whenever you want it).
Level 1 and MainMenu are imported from the old UE 5.6 project and both LOAD CLEAN. MainMenu runs. Full reference: Docs/Level1Import.md.
#The two goals, in order
1. Get Level 1 playable. It opens (164 actors, lighting intact) but nothing plays yet:
- World Settings GameMode still points at the missing
BP_SystemLinkGameMode— repoint
it at the ASLGameModeBase Blueprint. 2 PlayerStarts are already placed.
- Collision — ALREADY DONE, do not author any. Verified in engine 2026-07-31: **zero
- ⚠ One risk:
level1-fort-flooris complex-as-simple and a zero-thickness sheet — a
level meshes lack collision.** All 18 FBX_Level1_ / level1- meshes are set to CTF_USE_COMPLEX_AS_SIMPLE; the 8 others (SM_* prototyping, pMainTop, the two forrunner walls) use default with a convex hull each. The old project had already chosen complex collision — the same conclusion Docs/CollisionMeshes.md §5a reaches independently. The level should already be walkable.
paper floor with nothing beneath it. Sweeps handle it, but a dash (1625 cm/s) or a low spawn could punch through. If anything falls out of the world near the fort floor, that is the cause; fix by thickening it in Maya (Docs/CollisionMeshes.md §0), not by authoring hulls.
- Weapon pickups were removed (they were the old non-GAS chain). Their exact coordinates
are recorded in Docs/Level1Import.md §4 — re-place GAS pickups there.
2. Get MainMenu showing an actual main menu. The diorama is intact — 729 actors, 6 BP_MasterChiefMenuGuy, and the CineCameraActor + CameraRig_Rail driving LS_MainIntro — but the menu logic is deliberately gone. BP_MainMenuPlayerController and the old WB_* widgets were not migrated because CommonUI supersedes them, and its GameMode now uses the stock PlayerController. Build the screen on the existing CommonUI stack (Docs/UISystem.md, Docs/MenuSystemPlan.md) over the diorama, with LS_MainIntro behind it.
3. Then cleanup — the verified-safe 207-asset delete list and the naming pass are already computed and waiting in Docs/Level1Import.md §3 and §8. Nothing has been deleted.
#What this session actually changed
- Migrated Level1 + MainMenu +
Environment/MasterChief/Weapons/Generic/
LevelPrototyping (~706 assets). 3rdParty_Assets (3.8 GB) and Blueprints deliberately left behind.
- Fixed Level 1's compile errors by deleting the old pickup/MasterChief BP cluster
(7 assets) and removing 4 weapon pickup actors.
- Fixed MainMenu's ~200 load errors with
[CoreRedirects]inDefaultEngine.ini—
CreateExport failures 200 → 0, 207 crate actors recovered. See Level1Import.md §5a.
- Discovered the migration repaired a pre-existing bug: the project's own CommonUI text
and button styles had been referencing a font and two UI sound cues that did not exist in 5.7. They resolve now. Those 3 assets must never be deleted.
- Main floor collision built + validated in Maya (95 hulls) — but see the ⚠ below; it was
an experiment and is not being shipped.
#⚠ Two things not to re-derive
- **The Maya collision pipeline (
Tools/maya_*_main_floor_collision.py) was an experiment
Beepers is not using. The reusable part is the reference: Docs/CollisionMeshes.md (§0 hull thickness rule, §5a whether authored collision is needed at all**).
EditorAssetLibrary.delete_assetin a loop CRASHES the editor — access violation in a
background worker on the third delete. One delete per bridge call.
#Previous resume note (2026-07-31) — main floor collision is BUILT; UE import not yet done
mobility-cues is merged. main-floor.mb now carries 95 UE-ready collision hulls (91 boxes + 4 ramp wedges, 1140 tris) named UCX_SM_MainFloor_00..94. Built, validated and exported by script — not yet imported into Unreal, so nothing is PIE-proven.
Next step: import C:\3D-DEV\HaloProject\Maya\Level1\FBX\SM_MainFloor.fbx (Interchange → Assets → Collision → Import Collisions ON, auto-generate collision OFF), confirm 95 hulls land on the static mesh, then walk the ramps and the pit edge in PIE.
⚠ Open question that may retire this whole pipeline — Docs/CollisionMeshes.md §5a. The render
mesh is 436 triangles; the authored collision is 1140, i.e. **2.6× heavier than the thing it
approximates**. Use Complex Collision As Simple would be exactly as accurate, with no level merge,
no diagonal overhang and no maintenance. The hulls' only real advantage is solidity: the source is
an open shell whose ground plane is literally zero-thickness, so complex collision there is a paper
floor a dash could punch through. Import it both ways and test before relying on either — and
consider the upstream fix instead (give the floor thickness in Maya, then use complex).
Three scripts, all headless via mayapy.exe:
Tools/maya_build_main_floor_collision.py— generates the hulls into the scene (idempotent; clears
and rebuilds). Pristine pre-collision copy kept once at main-floor.pre-collision.mb.
Tools/maya_validate_main_floor_collision.py— naming, closed, convex, no overlaps, and a coverage
sweep over sampled walkable points. Currently: hulls=95 overlaps=0 coverage=1479/1479 VALIDATION PASSED.
Tools/maya_export_main_floor.py— combines duplicates of the 21 authored pieces into one render
mesh SM_MainFloor, exports Z-up FBX with the hulls, and re-imports it to verify the names survived. The authored geometry is never modified or saved by the export.
Method (why it isn't bounding boxes): every up-facing face is projected to plan, the union is cut into non-overlapping rectangles, and each rectangle is extruded from its own surface down to the next surface beneath it — so outlines, level heights and the vertical walls between them all survive. The four 16° ramps stay sloped convex prisms rather than stair-steps.
- Deliberate 3.2 cm simplification: the plates sit 3.2 cm proud of the ground plane. Honouring that
step costs 282 hulls instead of 91. LEVEL_MERGE_TOLERANCE in the builder is the one edit that buys the exact-shape version.
- Scope:
main-floor.mbonly — 21 near-flat pieces, no walls. The rest of Level 1 (layerFortMain,
layerMainWallsUE, layerMainTop, layerBasement) lives in other scenes and has no collision yet.
- The superseded
Tools/maya_create_main_floor_collision.py(bounding-box approach, writes a sibling
main-floor_collision.mb) is still in the tree — delete it or it will get run by mistake.
Full reference: Docs/CollisionMeshes.md — UE's naming contract, the per-type rules, the import settings, the case study with the real numbers, and what bit us. Footguns condensed in Docs/Footguns.md → Maya / Collision Export: the polygon-normal iterator crash, the by-name-only collision contract, the one-FBX/first-mesh-only limit, and the centre-tested grid quietly punching holes in the floor.
#Previous resume note (2026-07-31) — Maya MCP, then the MobilityModule socket
A Maya MCP was installed and wired up but has NOT been used yet — it needs a Claude Code restart.
maya-mcp0.6.1, pip-installed intoC:/Python313(user site-packages). Added to.mcp.jsonas
maya, launched as C:/Python313/python.exe -m maya_mcp.server — the explicit interpreter, not the console script, because maya-mcp.exe landed in the user Scripts dir and PATH here needs a restart to pick things up. Import verified under that interpreter.
- It does not launch or embed Maya. It talks to a running Maya over TCP on
localhost:7001. With
Maya open, in the Script Editor (Python):
import maya.cmds as cmds
cmds.commandPort(name=":7001", sourceType="python")
Failure is loud (MayaUnavailableError naming host/port/attempts), not silent. There is also a maya_mcp.maya_panel module (controller + preferences) that looks like a Maya-side UI for this.
#First task: add MobilityModule_Socket to MasterChief_Skeleton
Chosen because it is small, entirely objective, verifiable from both ends, and it unblocks the last loose end on the thruster cues.
USLGameplayCue_MobilityThrustersprefers a socket named exactlyMobilityModule_Socketon the TP
mesh and silently falls back to spine_04 when it is missing. It is falling back today.
- Socket goes on the upper back, between the shoulder blades (per
MobilityAssistModule.mdPhase 6).
- Verify across the seam: socket exists in Maya → survives export → Unreal's skeleton reports it → the cue
attaches to it rather than the fallback. Then the nozzle placement pass (LeftNozzleOffset / RightNozzleOffset) can finally be judged against real geometry.
⚠ **Working rule for anything Maya: edit the generator script and re-run it — do not hand-mutate a
scene.SourceArt/is gitignored** (3ef4515e), so unlike every C++ change there is no version
history to recover from. The_v1/_v2/_v3separate-script pattern already inTools/is the safety net.
Good fit for the MCP: objective checks (axis/units/bounds/pivots/normals/joint orientation), closing the generator loop without a human look-check, the motion-clearance test at the duct's pitch/yaw limits, and the Maya↔Unreal contract (bone names, up-axis, cm units, sockets, material slots). Not a fit: silhouette, proportion, whether it reads as Halo, animation weight. Those stay with Beepers.
#Session 2026-07-31 — cues fire, held-trigger fire; branch mobility-cues
Three things PIE-verified by Beepers: held-trigger fire, dash cue, double-jump cue. melee-target-lunge merged to main (PR-less, local merge + push).
Mobility cues now actually fire — they never had before. Full writeup: Docs/MobilityCues.md. Three separate silent failures, each of which alone was enough to produce nothing:
- A native
UGameplayCueNotify_Staticsubclass is never registered. The manager asset-scans
GameplayCueNotifyPaths and reads GameplayCueName off asset data; a C++ class is not an asset. Fixed by adding Blueprint subclasses in the scanned path.
- The asset NAME must derive the tag.
AbilitySystemGlobals.h:129— when a child's tag equals its
parent's, the engine clears the child's tag, re-derives from the asset name, and on failure restores the tag but not GameplayCueName. Our native classes are named GC_Mobility_Dash, so the editor derives a valid tag onto them; the Blueprint inherited it, tripped the branch, and GC_SL_Mobility_Dash derived the non-existent GameplayCue.SL.Mobility.Dash. Renamed the assets to GC_Mobility_Dash / GC_Mobility_DoubleJump — the SL convention is what broke them.
- The cue was gated on
HasAuthority(), which discardsExecuteGameplayCue's own predicted branch
and cost the acting player a round trip. Removed; it does not double-play.
⚠ An in-editor tag edit registers a cue for that session only (PostEditChangeProperty →
HandleAssetAdded). A broken asset looks fine until the next restart. **Never accept "it fired once"
— restart and fire again.** This wasted most of a session and made a correct diagnostic look like a
false negative.
Held-trigger fire — pressing fire during an equip now fires when the weapon is ready. ASLPlayerController::RetryPrimaryFireAfterEquip, bound to Triggered, latches on the blocked→available edge. Decisions: SingleShot gets exactly one shot (falls out of the latch for free), respawn included, pure hold-state.
- ⚠ Latch on
States.Weapon.Busy, NOTEquipping. Only the TP equip abilities ownEquipping;
FP owns only Busy, and every fire ability blocks on Busy. Watching Equipping fired the retry while the FP equip still held Busy, so it was blocked exactly like the original press — a silent no-op that looked like the input binding not working. New USLWeaponsComponent::IsWeaponBusy() is the accessor to use for anything gating on fire availability.
- Consequence:
Busyis broader than "equipping", so fire resumes whenever the weapon frees up. Wider
than the original ask; kept deliberately.
Next on the cues (the actual ask): real effects instead of the placeholder NS_RocketTrail, audio (dash sound was mid-authoring), a nozzle placement pass, and a montage. Authoring guidance + the Parent: On Execute pure-node trap are in Docs/MobilityCues.md.
#Session 2026-07-30 — melee target lunge; branch melee-target-lunge
aim-target-refactor merged (PR #33). New branch melee-target-lunge off main.
Melee now lunges at an acquired enemy — built, built clean, suite 18/18, NOT yet PIE-tested. Full reference: Docs/Melee.md → Target Lunge. Direction points at the target (horizontal only); speed interpolates MeleeLungeSpeed → MeleeTargetLungeSpeed across MeleeLungeRange, so a point-blank swing keeps the old nudge and a real gap gets a committed leap. Three new per-weapon values on USLWeaponDataAsset; no target = exactly the old behaviour.
- The target is chosen once, on the machine that owns the input (
ASLPlayerController::Melee→
FGameplayEventData::Target), and each machine recomputes the vector from its own copy of that actor. This is the dash direction lesson applied to a world-derived choice rather than an input-derived one. Verified in engine source that the whole event struct reaches the server (AbilitySystemComponent_Abilities.cpp:1923).
- ⚠ The one thing that fails silently: if
Targetdoes not survive the RPC, the server does a plain
forward lunge while the client leaps — reading as "melee feels inconsistent", not as replication. sl.Melee.Debug 1 logs the decision per machine; 2 draws it (server red, client green). Check it as a non-host client before trusting the feature.
- ⚠
LaunchCharacteris not replayed on a client correction (FSavedMove_Characternever stores
PendingLaunchVelocity). Pre-existing, but 1600 stresses it ~2.7× harder than 600 did. If it rubber-bands the fix is a root motion source, as the dash did — don't apply that blind, test first.
SystemLink.Melee.LungeSpeedcovers the curve as a pure function (endpoints, clamp past range, zero
range not dividing by zero, a misconfigured asset never making the lunge worse, monotonicity).
- Also:
SystemLinkCore.upluginnow declares its Niagara dependency — UBT had been warning on every
build since the thruster cues landed, and it only worked because the project happens to enable Niagara.
Still open on melee: never PIE-tested; MeleeImpactDelay is a fixed 0.3 s and not gap-aware; you can lunge off a ledge (deliberate, Kill Z catches it); corpses are valid lunge targets (consistent with the reticle and the damage sweep, but worth a pass if bodies persist).
Mobility (2026-07-29 art session, committed 2026-07-30): the dash + double-jump thruster cues (UGC_Mobility_Dash / UGC_Mobility_DoubleJump) were written and Live-Coding-verified on 07-29 but had never been committed — they are in now. Phase 4 (double jump) is code-complete and PIE-confirmed working by Beepers. Remaining wish for jump/dash: a montage to go with the thrust FX. There is deliberately no DoubleJumpZVelocity — see the note on USLMobilityModuleDataAsset; the second jump is CMC's native jump, which is what makes it predict correctly.
#Session 2026-07-29 — mobility suit rocket exhaust implemented
- Added native GameplayCue notifies for
GameplayCue.Mobility.Dashand
GameplayCue.Mobility.DoubleJump in Mobility/GameplayCues/SLGameplayCue_MobilityThrusters.
- Both cues spawn twin, armour-mounted Niagara jets using the existing
NS_RocketTrail: dash exhaust points
opposite the replicated dash vector; double-jump exhaust points down and burns slightly larger/longer.
- Uses
MobilityModule_Socketwhen authored and safely falls back tospine_04; jets auto-deactivate and return
to Niagara's component pool after the short burst.
- UE 5.7 Live Coding compile verified:
Result: Succeeded(2026-07-29).
- Visual placement still needs a PIE feel pass. If the nozzles do not sit on Chief's back plates, tune
LeftNozzleOffset / RightNozzleOffset or add the dedicated socket to MasterChief_Skeleton.
- Generated the first Maya 2026 MMA-3 backpack model from the approved concept via
Tools/build_mma3_thruster_backpack.py: editable .ma plus UE-ready skeletal FBX under SourceArt. Current LOD0 blockout is 65 mesh pieces / 16,328 vertices / 32,428 triangles, Z-up centimeters, with root, articulated thruster_l / thruster_r, FX_Thruster_L / FX_Thruster_R, and MobilityModule_Socket. Next art pass: fit-check against Chief, silhouette/detail refinement, final UV/bake, textures, collision and LOD1/LOD2.
- Screenshot-driven refinement completed as non-destructive v2 (
MMA3_ThrusterBackpack_v2.maand matching FBX):
replaced rectangular housing slabs with clean manifold custom-prism armor, tapered the chassis, split the rear shell into center/shoulder/lower plates, reduced and integrated the gimbals/nozzles, and tightened the envelope to ~43 cm wide × 50 cm tall. Maya rebuild verified clean at 66 pieces / 16,880 vertices / 33,528 triangles; no non-manifold/bevel warnings remain. Awaiting a v2 viewport look-check before final surface-detail/texture pass.
- v2 viewport review exposed reversed custom-panel normals and weak stacked-cylinder nozzles. Fixed non-destructively
in v3: custom prisms now conform normals and explicitly orient the outer/rear face toward -X; nozzle assemblies are continuous multi-station tapered bodies and flared bells with recessed dark throats and defined steel lips. Maya 2026 rebuild/export succeeded cleanly: 62 pieces / 17,168 vertices / 34,104 triangles. Files: MMA3_ThrusterBackpack_v3.ma and SK_MMA3_ThrusterBackpack_v3.fbx under the existing SourceArt folders.
- New rotating-duct design built as its own asset (earlier twin-nozzle iterations preserved):
MMA3_RotatingDuct_v1.ma + SK_MMA3_RotatingDuct_v1.fbx, generated by Tools/build_mma3_rotating_duct_v1.py. It uses the approved single paddle-duct concept and contains a static plenum/race, nested nozzle_pitch → nozzle_yaw joints, refractory-lined rectangular outlet, replaceable lip, four flow vanes, actuator/gearbox, and FX_Thruster. Maya 2026 build/export succeeded cleanly at 67 mesh pieces, 11,280 vertices, 22,304 triangles; Z-up bounds ~27.8 cm deep × 34 cm wide × 47.6 cm tall. Next: viewport review, motion-clearance check at pitch/yaw limits, then final UV/bake/textures/collision/LODs and Unreal integration.
- First Maya viewport review showed the v1 scene lying sideways: design coordinates were Z-up but Maya reopened
them in its normal Y-up workspace. Fixed non-destructively in MMA3_RotatingDuct_v2.ma and matching FBX by converting/freeze-baking the GEO and skeleton hierarchies into Maya-native Y-up before rigid skinning; FBX still exports with Z-up for Unreal. Clean rebuild verified with upright bounds: 47.6 cm Maya-Y height × 34 cm width.
#Active Branch
mp-phase1-lan — Multiplayer Phase 1: USLSessionService + USLSessionService_LAN over OnlineSubsystemNull, the main-menu boot path, the intro skip and the confirmation modal. 4 commits ahead of main. References: Docs/MenusAndOnline.md, Docs/Footguns.md (UI section).
Previously: ads-assault-rifle-shotgun — the ADS generalisation (LT aims any weapon), the bullet cone/spread work and the AR + shotgun tuning. Merged to main as PR #41. References: Docs/ADS.md.
Previously: level1-interactables — branched from main at 99941455 (PR #35 merged level1-import). Carried ASLDoor + USLShootableComponent (8 commits, 2026-08-07) and ASLTeleporter (2026-08-10). References: Docs/Doors.md, Docs/Teleporters.md.
<details><summary>Previous: <code>mobility-cues</code> (merged to main, PR #34)</summary>
Carried the cue registration/prediction fixes and the held-trigger fire feature (unrelated, but landed there). Reference: Docs/MobilityCues.md. All three features PIE-verified 2026-07-31.
</details>
<details><summary>Previous: <code>melee-target-lunge</code> (merged to main)</summary>
Branched from main after aim-target-refactor merged (PR #33). Reference: Docs/Melee.md → Target Lunge.
</details>
<details><summary>Previous: <code>aim-target-refactor</code> (merged, PR #33)</summary>
Branched from main (PR #32 merged). Plan: Docs/AimTargetRefactor.md (flowchart: Docs/AimTargetRefactorFlowchart.md).
</details>
#Session 2026-07-28 — Phases 1–4 done; BUG-028 reticle SOLVED; BUG-029 found
Done and verified in PIE:
- Phase 1 — shared aim vocabulary:
ESLAimTarget/FSLAimRay/FSLAimTarget(Public/Types/SLAimTarget.h),
BuildAimRay() / ClassifyHit() / ResolveAimTarget() on USLWeaponsComponent.
- Phase 2 —
USLPlayerPerceptionComponentonASLPlayerCharacter: local-player only, throttled by
sl.AimTarget.Rate (30 Hz), broadcasts OnAimTargetChanged only on kind change. sl.AimTarget.Debug 1 draws the ray + classification.
- Phase 4 —
USLHUDWidget::InitializePerception()binds the delegate and drivesUSLReticle::OnTargetDetected;
called from InitializeLocalPlayerHUD on every possession. SwapReticle now seeds the new reticle with the current aim state. Red Reticle Check deleted from WBP_SL_HUDWidget. Verified: host ✅, client ✅, weapon swap across two reticle classes ✅, client die→respawn→aim ✅.
- Debug commands (
Private/Debug/SLDebugCommands.cpp):sl.DumpTags(every owned tag WITH ITS COUNT, both
worlds, flags any count > 1), sl.DumpWidgets, sl.ClearWeaponLocks. These are what cracked BUG-028 — use them before theorising. (Known wart: sl.DumpWidgets' NOT ON SCREEN flag is wrong for layer-hosted widgets — IsInViewport() is false by design when a widget is parented into the primary layout. Read slateRealised.)
BUG-028 reticle symptom: root-caused and fixed — see the BUG-028 RESOLUTION entry in Docs/BugTracker.md. Short version: the HUD BP's Event Tick chain had unwired Is Not Valid pins on validated GETs of cached per-pawn references; a respawn invalidated one and the whole chain died silently, permanently, with no log.
#✅ DONE 2026-07-29: BUG-029 — ragdoll never cleared on observing clients
Fixed and verified in 3-player PIE. bRagdollActive is now replicated with OnRep_RagdollActive; authority is the only writer and EnableRagdoll() is a no-op off authority, so a client can no longer ragdoll a pawn the server considers alive. DisableRagdoll() now exists and restores capsule collision, mesh collision profile and the mesh's authored relative transform (captured in BeginPlay). Tests: SystemLink.Character.RagdollRoundTrip + RagdollIdempotence.
A bug the test caught while being written:ApplyRagdollStateoriginally usedIsSimulatingPhysics()as its
own idempotence guard, so wherever simulation declined to start (no physics asset) the enable path still
disabled the capsule while the disable path restored nothing. Idempotence is now tracked against an explicit
bRagdollApplied flag. Lesson: don't key an exit path on a symptom of the entry path.
<details><summary>Original report (kept for the RCA)</summary>
In 3-player PIE the host is invisible to every client after the host respawns: the observing clients' proxy is still ragdolling and has free-fallen to Z ≈ −54,559 (mesh ≈ −1,153,901) at terminal velocity, while the server's copy walks normally. EnableRagdoll() is driven by an anim notify (per-machine, non-authoritative — same family as BUG-023) and DisableRagdoll() does not exist; the design assumes the pawn is destroyed, so any machine that ragdolls a pawn it doesn't destroy is stuck forever.
Likely also the true cause of BUG-026 (a client can't red-reticle a respawned host because that proxy's physics bodies are a million units underground — the ECC_WeaponTrace change treated a symptom).
Full RCA: BUG-029 in BugTracker.md.
</details>
#✅ DONE 2026-07-29: Kill Z failsafe — falling out of the world kills you
ASLCharacterBase::FellOutOfWorld routes into the normal damage pipeline (FellOutOfWorldDamageEffect = GE_Damage, magnitude FellOutOfWorldDamage = 99999), so death, cues, scoring and respawn all behave as any other kill. Map side: TestMap World Settings KillZ. Verified in PIE with and without overshield.
Three things it took three iterations to get right, all worth remembering:
FellOutOfWorldfires every tick while below Kill Z, not once — needs a latch (bFellOutOfWorldHandled).
- Never call
Super— the engine default isDestroy(), which cancels the respawn scheduled by
StartServerDeathTimeout's weak lambda. Symptom: you die and simply never respawn.
- Overshield forces health damage to zero (
SLDamageExecution.cpp) no matter the magnitude, so the failsafe
clears the Overshield tag first — falling out of the world is removal, not combat, and no pickup may prevent it. Uses SetLooseGameplayTagCount(..., 0) rather than RemoveLooseGameplayTag, since loose-tag counts are known to inflate here.
Open decision: a fall currently records itself as the source object, so it scores as a suicide.
#✅ DONE 2026-07-29: ALL SIX PHASES of the aim refactor
WBP_SL_HUDWidget's Event Tick is empty. Suite 17/17. Verified in 3-player PIE.
- Phase 3 — fire path adopts
BuildAimRay(). Found two bugs while unifying:BuildAimRayhardcoded
bPrimary=true (secondary fire got the primary's range), and firing with no player controller traced from the world origin silently.
- Phase 5 — spread + equip-visibility moved to the perception component. Spread reads
GroundSpeed(2D)
rather than the BP's 3D velocity, so vertical motion no longer blooms the crosshair.
- Phase 6 — reticles cached, never destroyed. Measured with
sl.DumpWidgets Reticle: 6 → 10 across many
swaps, every instance _C_0 (growth is two new classes, not churn), exactly one non-collapsed per machine. The re-apply on activation is load-bearing — cached reticles hold stale state while collapsed.
#Requested: fall-depth failsafe — a player below a Z threshold dies automatically
Asked for 2026-07-29, straight after BUG-029. A player who ends up below a depth threshold should die rather than fall forever. Worth having on its own merits, and it would have turned BUG-029 from "the host is mysteriously invisible" into "the host keeps dying" — a far louder, far cheaper symptom.
Likely needs almost no new machinery. UE already has this: AWorldSettings::KillZ (World Settings → Kill Z) plus AActor::FellOutOfWorld, which fires when an actor drops below it. The work is to override FellOutOfWorld on ASLCharacterBase so it routes into the GAS death flow (authority applies the death effect / respawn) instead of the default behaviour, which just destroys the actor — a silent Destroy() on a player pawn is its own debugging trap. Set KillZ in the map's World Settings, well below any reachable geometry.
Two things to decide when building it: whether falling out of the world is a suicide for scoring purposes (it goes through the normal death path, so it will need a killer of some sort), and whether it should also fire for ragdolled corpses — with BUG-029 fixed a corpse should not be falling, so a corpse tripping KillZ is a signal worth logging loudly rather than silently handling.
#Also still open
Loose-tag counter inflation— FIXED 2026-07-29 (commita5dd138c).
USL_BlueprintLibrary::SetBooleanStateTag assigns via SetLooseGameplayTagCount instead of incrementing; all 15 C++ call sites converted and no raw Add/RemoveLooseGameplayTag remain outside the helper. Verify with sl.DumpTags, which flags any count above 1.
- HUD "out of ammo" message.
- MMA-3 Phase 5 — dash charge HUD indicator, still unbuilt. Phase 6's cues are built and committed
(2026-07-30); what's still wanted there is a montage to play alongside the thruster FX, and a PIE look at nozzle placement (tune LeftNozzleOffset/RightNozzleOffset or add MobilityModule_Socket to MasterChief_Skeleton).
- Undecided:
Docs/072326-12265-01.dmp(7.5 MB) and 59 untrackedDocs/Screenshots/Site/files. **Never
git add -A** — that sweeps in ~286 MB.
#Previous focus — CommonUI menus (paused)
menu-foundation — branched from main after grenade-refinement merged (PR #28, commit 5079aca). All the grenade drop/chain feel, pistol ADS (first-person-rendering swell fix), and muzzle-flash work is merged.
Focus: CommonUI menus — Phase 0b (controller-input pipeline + pause wiring). Plan spine is Docs/MenuSystemPlan.md; plumbing/footguns in Docs/UISystem.md; flow/online in Docs/MenusAndOnline.md. NEW (2026-07-16): the menu framework is becoming a Fab product — Docs/MenuKitExtractionPlan.md (extraction + AI-native manifest/generator; see the 2026-07-16 section below). Action bar still ships first.
#🎯 NEXT MILESTONE — Settings menu = the menu "stopping point" (decided 2026-07-20)
Finish menus to a stopping point, and that point is a working Settings menu. After it lands, the menu push pauses and the next initiative is the Foundry Game Agent Bridge (Docs/Foundry/Game_Agent_Bridge.md, deferred until this milestone is met).
Definition of done (the gate): a Settings screen reachable from the pause menu, fully gamepad-navigable, that reads and persists player settings (hybrid settings plan in Docs/MenusAndOnline.md — player settings ship first), with the command bar showing Back.
Open menu threads to clear on the way there:
- Command bar — DECISION 2026-07-20: HARDCODE Back + Accept, skip the dynamic
CommonBoundActionBar. The
bar is display-only (B already closes via bIsBackHandler; A already clicks the focused button), so a fixed strip is safe and simpler. Full recipe: Docs/CommandBarBackButton.md. Build WBP_SL_CommandPrompt (CommonActionWidget glyph + CommonTextBlock label, 2 exposed vars) → put 2 in WBP_SL_CommandBar (Back = IA_SL_UI_Back, Accept = IA_SL_UI_Confirm) → embed in each screen (Option A: constructs with the screen so the glyph resolves while IMC_SL_UI is live). Dynamic bar reframed as a MenuKit-product upgrade, not a game blocker.
WBP_SL_BoundActionButton— already fully built (verified via bridge 2026-07-20): tree is
HorizontalBox_0 → Text_ActionName [CommonTextBlock] · Spacer · InputActionWidget [CommonActionWidget], both bind names/types correct. The old "needs a root-swap / missing glyph" note was STALE — a prior bridge session built it. It is unused by the hardcoded approach; parked for the MenuKit dynamic-bar upgrade. (Note: reading BindWidget props off the CDO returns None even when bound — binding is per-instance, not on the CDO; don't use CDO reads to test a bind.)
- Bridge fact learned 2026-07-20: it can build widget trees under an existing root (duplicate a rooted
seed → clear → build), but cannot mint or swap a root (RootWidget protected read+write).
- Verified state (2026-07-20):
IA_SL_UI_Back.ActionDescription="Back",IA_SL_UI_Confirm.ActionDescription
="Select"; WBP_SL_PauseMenu CDO has bIsBackHandler + bIsBackActionDisplayedInActionBar both true; shared layout WBP_SL_PrimaryGameLayout root = Overlay_54 holding HUDLayer/GameStack/MenuStack/ModalStack.
#Session 2026-07-26 — setting descriptions (data-driven) DONE; sensitivity merged to one row
Setting copy is now data-driven and focus-published. PIE-verified ✅ (Beepers: "all that is working").
FSLSettingCopy(Public/UI/SLSettingCopy.h) — newFTableRowBase:DisplayName+Description, plus a
static Resolve(Handle, OutName, OutDesc) that no-ops on an unset handle so a row can still author both inline. Asset: DT_SL_SettingCopy (/Game/SystemLink/Data/, next to DA_SL_ProTips), 5 rows — LookSensitivity, InvertLookY, InvertLookX, ADSSensitivity, FieldOfView.
Why a DataTable and not a ProTips-style data asset: the row NAME is the setting id (no duplicate key
field), CSV round-trips for bulk copy edits, and FDataTableRowHandle gives the designer a **dropdown of
valid rows** instead of a typed FName. Pro Tips are a pool you sample; setting copy is a keyed lookup.
Deliberately no UDeveloperSettings pointer — the handle already carries its table, so a settings
pointer would be a second source of truth the picker ignores. (Cost: a MenuKit licensee swapping tables
re-points rows.)
- Rows carry one picker.
USLRotatorRow+USLSettingsRowWidgeteach gained
UPROPERTY SettingCopy (meta=(RowType="/Script/SystemLinkCore.SLSettingCopy") — the row-name dropdown is filtered by the asset's RowStructure tag, which is the path name), Description, and ApplySettingCopy() called from NativePreConstruct — so the designer previews real copy, and the duplicate SetDisplayName-per-row graph work is gone. Table wins over inline values when it resolves.
- Delivery via the focus coordinator, not per-row wiring.
ISLFocusablegained non-pure
virtual FText GetFocusDescription() + GetFocusDisplayName() (existing implementers compile unchanged; buttons return empty = "clear the panel"). USLMenuFocusSubsystem publishes OnFocusedCopyChanged(DisplayName, Description) (BlueprintAssignable) + GetFocusedDisplayName() / GetFocusedDescription(), broadcast from SetFocused only — NOT on focus loss, because Slate sends focus-lost before focus-gain and blanking there flickers the panel on every row move. Its static Get is now BlueprintPure + DefaultToSelf (one node in BP). One screen binds once; every future screen/control gets descriptions free. (Shipped first as one-param OnFocusedDescriptionChanged; renamed when the heading was added — a delegate named "description" carrying a title is the kind of drift that costs a session later.)
WBP_SL_Settingswiring (Beepers, in-editor): blocks namedHeadingText+DescriptionText; Construct →
Get Menu Focus Subsystem → Bind Event to On Focused Copy Changed → custom event On Focused Copy Changed → Set Text on both (DisplayName → heading, Description → body). PIE-verified ✅. (A SET on Text is fine — TextBlock.h declares it BlueprintSetter="SetText", so it's a real SetText() call, not a silent property poke.)
- ⚠ Destruct-unbind footgun hit and removed —
Get Menu Focus Subsystemreturns None during teardown
("Accessed None … CallFunc_Get_ReturnValue" on the unbind node), and the unbind is unnecessary anyway: BP bindings are weak and AddUnique compacts stale entries. Also: "Unbind ALL Events" clears the delegate for every listener (EX_ClearMulticastDelegate) — never use it on a subsystem delegate. → Docs/Footguns.md.
- Sensitivity merged to ONE row (Beepers):
WBP_SL_Settingsrows are nowRow_Sensitivity,Row_InvertY,
Row_InvertX, Row_ADS, Row_FOV under Overlay_0 → SettingsBox → RowBox. Both LookSensitivityX/Y stay in the save game (SLPlayerController::Look reads them independently) — the single row drives both setters, so an "advanced / separate axes" toggle later is pure UI, no save migration.
- Build clean (
Result: Succeeded). ⚠ Pre-existing deprecation to fix before the next engine bump:
SLSettingsRowWidget.cpp:29 uses EKeys::Virtual_Accept → Virtual_Gamepad_Accept.GetVirtualKey().
Profile subsystem re-scoped for split-screen + multiple profiles (built ✅, PIE-UNTESTED). Decision this session: profiles must support split-screen, and it's fine for a player to pick a profile.
USLPlayerProfileSubsystemis now aULocalPlayerSubsystem, not aUGameInstanceSubsystem. The old
scope was a split-screen bug: one instance for the whole game meant both players shared a profile, so P2 inverting Y flipped P1 too. Now each local player owns its profile, and a widget resolves its own player's automatically.
- New accessors
GetForWidget(BP: Get Player Profile Subsystem (Widget), defaults to self) /
GetForPlayerController. The 4 C++ call sites (SLPlayerController.cpp ×3, SLPlayerCharacter.cpp) use them.
BlueprintPuregetters added (GetLookSensitivityX,GetFieldOfView, …) so graphs read AND write
through the subsystem and never touch the save game type. They fall back to the save game's class defaults, so defaults live in exactly one place.
- One save slot per profile —
SLPlayerProfile_<Name>,SlotUserIndexalways 0 (profiles disambiguate by
name, not controller slot). LoadProfile(Name) flushes the outgoing profile, loads/creates, broadcasts. Pre-profile saves in the unsuffixed SLPlayerProfile slot are adopted once into Default.
- ⚠ BP BREAKAGE EXPECTED: any graph using
Get Game Instance Subsystem → USLPlayerProfileSubsystem(that's
how WBP_SL_Settings reads today) will fail to compile. Swap to Get Player Profile Subsystem (Widget), and take the chance to read via the new getters instead of GetProfile → field.
- Full writeup:
Docs/PlayerProfiles.md(scope rationale, storage, lifecycle, read/write contract, how to
add a setting, and §8 what's NOT built: profile index/registry, create/rename/delete, picker UI, EOS identity).
Split-screen is now a committed target (decided 2026-07-26) — NOT implemented. Profiles + menu focus are per-local-player and therefore ready; nothing yet creates a second local player, splits the viewport, routes a second input device, or gives each player their own UI layers. Work item + checklist: MenusAndOnline.md §9 (biggest unknown: whether USLPrimaryGameLayout/ASLPlayerHUD construct per local player — that path was written single-player).
#🏁 MILESTONE GATE MET — Settings menu seeds, writes back, and persists (2026-07-26)
All five settings survive a full PIE stop/restart. Beepers verified: change every row → B to close → stop PIE → relaunch → reopen Settings → all values intact. That was the defined stopping point for the menu push, so the Foundry Game Agent Bridge (Docs/Foundry/Game_Agent_Bridge.md) is now unblocked.
Row config pattern that landed (better than the original plan). Range lives on the ROW, not the screen: WBP_SL_SettingsRow_Rotator exposes Use Numeric Range / Minimum / Maximum / Step / Decimals, and its own Event Pre Construct calls SetNumericRange behind a Branch on Use Numeric Range. Per-instance in the details panel; Off/On rows leave the box unticked and keep their authored Options. Child PreConstruct runs before the parent's Event Construct, so the screen's seeds always land after the range exists.
Row_Sensitivity0.5–10 step 0.5 ·Row_ADS0.1–2.0 step 0.1 ·Row_FOV80–110 step 5 (Decimals 0).
- Screen Construct →
Sequence→ five seeds, all reading the new subsystem getters withBroadcastOFF.
SaveProfileonOn Deactivated, behind anIsValidon the subsystem (teardown guard).
Two bugs found and fixed on the way — both looked like "settings won't save":
- Rows were bound to
OnValueChanged, which carries the INDEX. Every change wrote 0/1/2… into a clamped
setter, flooring it (FOV→70, ADS→0.1, sens→0.05). Rebound to OnFloatChanged / OnBoolChanged; verified in the saved asset (OnValueChanged now absent, all five BndEvt names are SLOnRotator*Changed).
- The save file already held that garbage — every field sat at its clamp minimum, so the (working) seed
faithfully displayed junk, and FOV showed 80 because SetSelectedByFloat(70) picks the nearest notch. Deleted SLPlayerProfile_Default.sav + the legacy SLPlayerProfile.sav for a clean baseline → FOV opened at 95. If settings ever look wrong after a wiring change, suspect the .sav before the graph.
Debug note: a bound event's BndEvt__… function name is baked at creation and **survived both a widget
rename and a class change** — it advertised SLOnFloatSettingChanged (a slider delegate) on a rotator row and
caused a wrong diagnosis. Don't trust it; check the name table or re-create the node. → Docs/Footguns.md.
#Mouse smoothing as a player setting + test suite grown to 11 (2026-07-27)
Mouse smoothing is now a player choice, not a global default. Beepers plays on a trackball, which produces coarse, low-poll deltas that average out well — but the same smoothing on a 1000 Hz mouse is pure added latency. Shipping the developer's device preference as everyone's default was the thing to avoid.
USLInputModifierMouseSmooth(Public/Input/) readsMouseSmoothingoff the player profile each frame and
passes input through untouched when off. Smoothing is a half-life in seconds, so feel is identical at 60 and 240 fps. Engine's stock Smooth couldn't be reused: it's baked into the mapping with no runtime switch, and it's MinimalAPI, so its implementation isn't linkable from another module.
bMouseSmoothingon the profile, default OFF (raw is right for a normal mouse). Applied to the
Mouse2D mapping only — the gamepad keeps its dead zone and nothing else.
- Assets done:
DT_SL_SettingCopygained aMouseSmoothingrow;Row_MouseSmoothingadded to
WBP_SL_Settings. ⚠ Beepers still owes the graph wiring (seed from Get Mouse Smoothing on Construct, bind On Bool Changed → Set Mouse Smoothing) — the bridge can build widget trees but not Blueprint graphs.
Automation suite now 11 tests, all passing headless in ~1s. Added this session:
Mobility.Dash.Direction+.YawRoundTrip— the direction rules that broke in play (back folds forward,
strafe-back keeps only the sideways half, pitch can't leak in) and the client→server yaw encoding, which is load-bearing for MP. Required extracting a pure ProjectDashDirection(Input, Yaw) — an improvement anyway.
Content.*×4 — every setting-copy row has non-empty text; everySettingCopyhandle inWBP_SL_Settings
resolves; no null modifiers in IMC_Default; the mobility module's ability set actually grants the dash ability + attribute set. These target content that silently doesn't match code, this project's most expensive bug class — and three of the four would have caught a mistake made in this very session.
Run: `UnrealEditor-Cmd.exe <project> -ExecCmds="Automation RunTests SystemLink" -unattended -nopause
-nosplash -nullrhi -testexit="Automation Test Queue Empty"` (PowerShell, not the Bash tool).
#📌 Captured backlog (not started)
- HUD "out of ammo" message for the active weapon. Nothing tells the player why firing stopped — the
ammo strip goes empty but there's no callout. Wants a HUD message (and probably a dry-fire cue) when the active weapon's magazine and reserve are both empty. Bind off the existing weapon delegates rather than polling — see feedback_weapon_hud_binding / Docs/UISystem.md. Noted 2026-07-27.
#Mobility Assist Module — Phases 1–3 done, dash working (2026-07-27) — branch movement-feel
Dash is in and PIE-verified (Beepers: "it works well"). Plan + the five deviations from it: Docs/MobilityAssistModule.md (status block at the top of Implementation Phases).
- C++:
SLTagsgainedEvents.Mobility.*,States.Character.{Dashing,DashCooldown,HasDoubleJumped},
Abilities.Mobility., GameplayCue.Mobility.; USLMobilityAttributeSet (DashCharges/MaxDashCharges); USLMobilityModuleDataAsset; USLMobilityComponent on ASLCharacterBase; USLGameplayAbility_Dash; DashAction + Dash() on the controller.
- Assets:
AS_MobilityModule(grants the dash ability + attribute set),DA_SL_MobilityModule_MMA3
(/Game/SystemLink/Data/), IA_SL_Dash mapped to B and Left Shift in IMC_Default (32 → 34 mappings, read-back verified), BP_SL_MasterChief.DefaultMobilityModule set.
- Input note: B and Left Shift were the only comfortable free binds — every other face button, shoulder,
trigger, stick-click and DPad_Down is taken. Shift is free because sprint was cut.
- ⚠ Still missing by design: no dash VFX/SFX (cues = Phase 6, tag fires but nothing is registered), no
HUD charge indicator (Phase 5), and double jump is still raw JumpMaxCount with no cost or cue (Phase 4).
Dash tuned + three bugs fixed (2026-07-27, all PIE-verified). Final feel: 3.25 m over 0.2 s, exit speed 250, ONE charge back after 1.25 s — all on DA_SL_MobilityModule_MMA3, tunable without a rebuild.
- Raw
Velocitywrites aren't network-predicted → rubber-banding for a non-host client. Now a root motion
source (ApplyRootMotionConstantForce), which CMC replicates and replays.
- The root motion task's
OnFinishnever fired → ability never ended →States.Character.Dashingstuck →
its own BlockAbilitiesWithTag blocked every later dash. Presented as "one dash and it never recharges" while charges were full at 1/1. Lifetime is now a WaitDelay. Diagnosing this took reading the live ASC tags over the bridge — the resource was a red herring.
- Dash direction must be resolved by the input owner. Inside the ability, the server recomputed from a
remote pawn's stale input, fell back to "forward", and its correction overrode the client — every dash went forward on screen. Now resolved in ASLPlayerController::Dash and sent as a yaw in EventMagnitude.
All three are in Docs/Footguns.md under GAS — abilities that move the character; they generalise to any
directional predicted ability (dodge, thrust, directional melee).
#Base movement authored (2026-07-27) — branch movement-feel
Every movement value was an untouched engine default until this pass. Base feel is now authored in C++ (ASLPlayerCharacter::ConfigureMovementDefaults), not on the character Blueprint, so it diffs and has history — same rationale as the FP camera work. Full reference + tuning method: Docs/MovementFeel.md.
- Jump arc 685 / gravity 1.85 → 1.29 m apex in 0.378 s (was 0.90 m in 0.43 s).
AirControl0.05 → 0.35
(0.05 is effectively none — you were committed the moment you left the ground). PIE-verified, Beepers: "I like that a lot." CDO read-back confirms the Blueprint isn't shadowing the C++ values.
- The arc is solved, not guessed:
apex = v²/2g,t = v/g,g = 980 × scale. Pick the feel, solve for the pair.
JumpMaxCountstays 1 — the second jump is granted by the Mobility Module on equip.
- Sprint is deliberately out (decided 2026-07-27): Doom doesn't have it and it competes with dash.
- ⚠
MobilityAssistModule.md'sDoubleJumpZVelocity = 700is now stale — it was "+70% of base jump"
against the old 420, but against 685 it's a second identical jump. Re-derive before Phase 4.
#Look feel — invert baseline fixed, sensitivity rescaled (2026-07-27, merged PR #30)
- "Invert Look Y is backwards" was a BASELINE bug, not a toggle bug.
IMC_Defaultnegated look Y and
bEnableLegacyInputScales=True applies InputPitchScale = -2.5 — two negations, so the un-inverted state was already inverted and the toggle faithfully flipped a wrong start. Removed the Negate from both IA_SL_Look mappings (mouse + gamepad, must be both) leaving exactly one negation. Off = normal, On = inverted. Ruled out on the way: option order (['Off','On'], correct) and device mismatch (both mappings identical).
- Also removed a
DeadZonethat had appeared on the Mouse2D mapping — dead zones suit stick deflection, not
mouse deltas. It got there because changing a modifier's type in the dropdown replaces the entry instead of deleting it. → new Input / Enhanced Input section in Docs/Footguns.md.
- Sensitivity rescaled + integer scale. 0.5–10 step 0.5 was 20 notches with ~14 unusable.
SetNumericRange
gained a trailing bLabelAsSteps: labels become the notch number 1..N while NumericValues keeps real magnitudes, so the setter/clamp/profile stay in multiplier units and no save migration is needed (SetSelectedByFloat re-selects the nearest notch). Look = 0.25–2.5 shown as 1–10 (4 = 1.0, 6 = 1.5, matching Halo's common 6); ADS = 0.1–1.0 shown as 1–10 (6 = 0.6 default). FOV stays in real degrees.
- Known gaps in look feel (not addressed): one multiplier serves both mouse and gamepad (they need
separate values — do this before split-screen, where mixed devices are normal), and gamepad look is likely framerate-dependent (AddPitchInput gets a per-frame stick position with no DeltaTime scaling), which quietly invalidates any sensitivity tuning done at a different FPS.
NEXT: Video/Audio tabs, real tab navigation, and key rebinds are all still deferred (§7 of
SettingsMenuBuildout.md). Before more UI, the two structural follow-ups are the profile index + picker
(Docs/PlayerProfiles.md§8) and split-screen (Docs/MenusAndOnline.md§9).
#Session 2026-07-25 — WBP_SL_Page page base started; editor hard-crash diagnosed
WBP_SL_Page= the new PAGE BASE for menu screens (/Game/SystemLink/UI/Menus/). Created by duplicating
WBP_SL_Pause → renaming WBP_SL_Pause1 → WBP_SL_Page. Screens should build on this going forward instead of each re-deriving the pause layout. Last good save 2026-07-25 3:40 PM.
- ⚠ Editor hard-crashed while iterating on it (3:42:58 PM) — died with no
LogExit/fatal in the UE log.
Cause was not the GPU: Windows Error Reporting shows UnrealEditor-Slate.dll, 0xc0000409 P9=2 (FAST_FAIL_STACK_COOKIE_CHECK_FAILURE), and the System log is clean (no nvlddmkm/WHEA/TDR) — real Slate memory corruption, distinct from the Raptor Lake "device hung" family. Preceded by two ensures: Attempting to enqueue CD_SL_Xbox for compile while compiling: WBP_SL_Page_C, then Slate Array has changed during ranged-for iteration!. Compile WBP_SL_Page from the Content Browser with its widget editor closed; restart the editor after duplicating a CommonUI screen. → Docs/Footguns.md.
- Context: graphics driver switched Studio → Game Ready this session (now 610.74, 7-2-2026); the driver
initialized cleanly (RTX 4080 SUPER, D3D12 SM6) and is not implicated in the above crash.
#Session 2026-07-21 — Settings rows reviewed, KBM cursor FIXED, rotator row started
✅ RESOLVED (verified via bridge 2026-07-26) — this whole "rootless / one manual step" block was STALE.
WBP_SL_SettingsRow_Rotatoris fully built:Border → RowBox [HorizontalBox]→
Spacer · LabelText · LeftArrowButton · SizeBox_0(MyText) · RightArrowButton · Spacer, so the required
MyTextbind is satisfied. AndWBP_SL_Settingswent further than the old plan — every row is a
WBP_SL_SettingsRow_Rotator_C now, not just the two Invert rows. See the 2026-07-26 section above.
Status: UMG for the Gameplay tab is authored (trees/graphs built since 2026-07-20). Slider Row_SensX adjusts via arrow keys AND controller. C++ this session is REBUILT + live (cursor fix + USLRotatorRow).
The "only a button is focusable in CommonUI" problem + how it's handled now. A raw USlider/CheckBox won't take gamepad focus (only a UCommonButtonBase does). Current fix (C++): USLSettingsRowWidget is itself SetIsFocusable(true) and intercepts input in NativeOnKeyDown — Left/Right → HandleAdjust(±1), Accept → HandleAccept(), Up/Down fall through to Slate nav; focus highlight via OnRowFocusChanged(bool) BP hook (new NativeOnAddedToFocusPath/RemovedFromFocusPath). Slider steps by StepSize (5% fallback), toggle sets off/on + Accept flips. InputCore added to Build.cs for EKeys. Review verdict: sound, no correctness bugs.
⚠ UMG requirement for the row approach: the inner USlider / CheckBox must have Is Focusable = OFF so
the ROW is the only nav stop — else focus lands on the inner control and the row's OnKeyDown never fires.
DECISION — use a UCommonRotator for discrete settings (it IS a UCommonButtonBase = a button → focusable for free). Sidesteps the focusable-row hack for discrete choices. Port the 5.6 pattern from C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\SystemLinkSettingsRotator.*: extends UCommonRotator, PopulateTextLabels/ShiftTextLeft/Right, NativeOnClicked(){} (suppress click-cycle), Left/RightArrowButton BindWidgets for mouse, focus/hover → description broadcast, SetIsFocusable(true).
- Plan: HYBRID + rotator-IS-the-row. Rotators for discrete (Invert Look Y/X now → Off/On; later Hold/Toggle,
quality presets); keep the slider row for continuous (Sens X/Y, ADS, FOV — too many steps to cycle).
USLRotatorRow : UCommonRotator— WRITTEN + REBUILT + registered ✅.SLRotatorRow.h/.cpp(confirmed
loadable via bridge: /Script/SystemLinkCore.SLRotatorRow). Verified against engine source (UE_5.7/.../CommonUI/.../CommonRotator.h/.cpp): UCommonRotator is a UCommonButtonBase (focusable natively) and handles gamepad Left/Right itself via NativeOnNavigation/HandleNavigation → no directional wiring needed. It requires a BindWidget UCommonTextBlock named MyText (the value display). SetSelectedItem (seeding) does NOT fire the rotate event; ShiftText*/nav/click DO fire OnRotatedEvent(Index, bFromNavigation) → bound in NativeOnInitialized, so seeding won't feedback-loop. Our class adds: SetOptions, SetSelectedIndex(idx,bBroadcast), SetBoolValue(b,bBroadcast)/GetBoolValue, OnValueChanged(int32) + OnBoolChanged(bool), optional LabelText/DisplayName + mouse Left/RightArrow Button, NativeOnClicked→ShiftTextRight (A advances), OnRowFocusChanged highlight hook.
- ⚠ The two bullets below are HISTORY — the row is built and every settings row is a rotator (see 2026-07-26).
WBP_SL_SettingsRow_Rotator— CREATED via bridge (parentUSLRotatorRow, saved) but ROOTLESS. Made with
WidgetBlueprintFactory(parent_class=USLRotatorRow) so no reparent was needed (dodged the reparent-crash footgun). Confirmed in 5.7: WidgetTree.RootWidget has no Python setter (set_editor_property('root_widget') → "Failed to find property"), and WidgetTree/ParentClass are read-protected on the WBP (use unreal.find_object(bp,'WidgetTree')). So the root must be added by hand — see RESUME HERE above. There's a harmless orphan RowBox HorizontalBox left in the tree from the probe; ignore/replace it.
- NEXT after the root exists (bridge):
new_object(cls, outer=wt, name=...)+PanelWidget.add_childare
proven — build LabelText/MyText/Left+RightArrowButton under the root, set arrow bIsFocusable=false, compile+save. Then WBP_SL_Settings: swap the two Invert toggle rows → rotator (SetOptions({Off,On}), SetDisplayName; graph SetBoolValue(profile,false) seed on construct + bind OnBoolChanged → SetInvertLookY/X). (This is also the deferred USLDropdownRow slot.)
PIE findings (verified via bridge + testing):
- ✅
WBP_SL_Settings+WBP_SL_PauseCDOs confirmedInputModeOnActivate=MENU,bPauseGameWhileActive=True.
- ✅ Controller B backs out of Settings→Pause and Pause→resume (Back handling solid).
- ✅ CommonInput supports mouse (
bSupportsMouseAndKeyboard=True,DefaultInputType=MouseAndKeyboard).
- ℹ️ Esc quits PIE — the editor reserves Esc to stop the session; NOT a Back bug (Esc = Back is fine in a
build). For KBM testing add a non-Esc back key (e.g. Backspace) to IA_SL_UI_Back.
- ℹ️ Settings widget Navigation rules all = Escape — benign: rows consume Left/Right in OnKeyDown so the nav
rule never fires; vertical nav resolves through the parent box.
- ✅ KBM mouse cursor vanishes in the menu — FIXED + REBUILT + WORKING (Beepers confirmed cursor works). Root cause via live
bridge inspection of the PIE PC: bShowMouseCursor=False while the menu was up. CommonUI hides the OS cursor whenever the active input device is Gamepad (pads navigate by focus); entering the menu from the pad left it hidden, and it never flipped back on when the mouse was used. Forcing bShowMouseCursor=True on the live PC restored the cursor + clicks (proved the rest of the pipeline is fine). Fix in USLCommonActivatableWidget (Menu-mode only): HandleInputMethodChanged now shows the cursor for MouseAndKeyboard / hides for Gamepad (called with the current type on activate → correct initial state); NativeOnMouseMove reveals it on physical mouse use as a fallback for when CommonUI hasn't flipped the device yet. SetMenuCursorShown gates on InputModeOnActivate==Menu. Close path already hides it (ASLPlayerController::RestoreGameInputMode sets bShowMouseCursor=false on last pop). Rebuilt + confirmed working in PIE. Remaining nicety to spot-check when convenient: tap pad after using mouse → cursor hides again; close → cursor gone in gameplay.
#Done this session (2026-07-20)
- Pro Tips converted to a Data Asset.
USLProTipDataAsset(+USLProTipSettingsDeveloperSettings) replace
the raw-JSON loader; DA_SL_ProTips (/Game/SystemLink/Data/) populated with 40 tips via the bridge; per-tip DisplayLabel; DefaultGame.ini points the setting at it. PIE-verified data resolution. Old SystemLinkProTips.json now orphaned (safe to delete after a PIE gameplay check). ⚠ C++ rebuilt already.
- New forward-looking docs:
Docs/AutoresearchOpportunities.md,Docs/Foundry/Game_Agent_Bridge.md.
- Command bar keyboard handling — DONE. Bar is gamepad-only:
WBP_SL_CommandBarbinds
CommonInputSubsystem::OnInputMethodChanged (+ GetCurrentInputType on Construct) and hides its prompts on KBM. Briefly authored then deleted CD_SL_KBM (glyphs never render when hidden). Swapped CD_SL_Xbox glyphs to the Retro set (PIE-verified, Beepers likes it). Full recipe: Docs/CommandBarBackButton.md.
- Settings menu — STARTED (Gameplay tab MVP). Backend done + compiled:
USLPlayerProfileSaveGame
(USaveGame, BlueprintReadOnly fields) + USLPlayerProfileSubsystem (GameInstance; load/create, clamped Set*, OnPlayerProfileChanged, SaveProfile); SLPlayerController::Look applies sensitivity + invert + ADS-mul; SLPlayerCharacter::SetBaseFieldOfView for FOV. Invert-Y/sensitivity NOT yet PIE-tested (no UI yet). Three EMPTY WBPs created (parents set, no tree — bridge can't add a root): WBP_SL_SettingsRow_Slider/_Toggle (USLSliderRow/USLToggleRow), WBP_SL_Settings (USLScreenWidget). NEXT = author the trees/graphs + wire the pause Settings button per Docs/SettingsMenuBuildout.md (full recipe: row widgets → screen seed/bind/SaveProfile → PushWidgetToLayer(Menu, WBP_SL_Settings)). Rotator/ dropdown, Video/Audio/rebinds, real tabs all deferred (§7 there).
#Menu work — where we are (2026-07-06)
The C++ foundation base-class kit is already built (USLPrimaryGameLayout push/pop/clear/getActive, enhanced USLCommonActivatableWidget, USLScreenWidget/USLModalWidget/USLButtonBase/USLTabListWidget/ USLListEntryWidget/settings rows). This session — Phase 0b C++ (UNCOMMITTED, needs rebuild):
USLCommonActivatableWidget— addedbPauseGameWhileActive; pause/unpause tied to activate/deactivate,
standalone-only (footgun 6.12), so it survives any close path (Start toggle, B/Esc, pop).
ASLPlayerController—OpenPauseMenu/ClosePauseMenu/TogglePauseMenu+PauseAction(bound Started) +
PauseMenuClass; IsMenuOpen() dedups. Pushes to the Menu layer via the HUD; unpause is the widget's job.
Config/DefaultGame.ini—[CommonInputSettings]withbEnableEnhancedInputSupport=True(asset refs
commented out until the data assets exist, to avoid dangling-path startup errors).
Phase 0b progress:
- ✅ C++ pause wiring compiled (rebuild done). ✅
bEnableEnhancedInputSupportin ini.
- ✅ Input actions authored (bridge, 2026-07-06): 7
IA_SL_UI_*in/Game/SystemLink/UI/Input/Actions/
(Confirm/Back/Navigate/NextTab/PrevTab/Pause/Scoreboard); IMC_SL_UI (11 mappings) in /Game/SystemLink/UI/Input/. Navigate intentionally unmapped (Slate default nav drives DPad/stick/arrows).
- ✅
IA_SL_UI_Pausemapped inIMC_Default(Gamepad_Special_Right+P) — FIXED 2026-07-09. The
earlier "added" claim was WRONG: the mapping was never in the asset (writes to the deprecated mappings array read back as 0; the real store is default_key_mappings.mappings). Verified by reading it back — 32 mappings now, Pause present. This was why the pause menu wouldn't open. → Footguns (Bridge/Editor).
Global input-leak guard — DONE (C++, 2026-07-06, needs rebuild): menus can't leak gameplay actions, no per-mapping code. Two layers:
USLScreenWidgetalready defaultsInputModeOnActivate = Menu→ CommonUI suppresses gameplay input.
- Belt-and-suspenders:
ASLPlayerController::Push/PopGameplayInputSuppression()(ref-counted) removes/re-adds the
gameplay InputMappingContexts; USLCommonActivatableWidget calls it on Menu-mode activate/deactivate.
- Consequence: while a menu is up, Start no longer closes it (that action is in the suppressed gameplay
context) — close is CommonUI Back (B/Esc). Start opens, Back closes.
CommonUI data asset — DONE (bridge): DA_SL_CommonInputData (BP : UCommonUIInputData) with EnhancedInputClickAction=IA_SL_UI_Confirm, EnhancedInputBackAction=IA_SL_UI_Back (fixes the two-input-systems pitfall). DefaultGame.ini InputData now points at it. DefaultClickAction/DefaultBackAction (DataTable rows) left empty — that's the action-bar display layer, bundled with glyphs below.
Phase 0b continued — DONE this session (bridge, 2026-07-06, all saved):
- ✅ Rebuild done (suppression methods + activatable wiring live).
- ✅
DT_SL_InputActionscreated in/Game/SystemLink/UI/Input/(row structCommonInputActionDataBase):
rows Confirm→DisplayName "Accept", Back→"Back". Text placeholder (decision: text now, real CD_SL_ glyph sets deferred — Confirm/Back already function* via the EI actions; this is only the prompt-display layer).
- ✅
DA_SL_CommonInputData—DefaultClickAction={DT,Confirm},DefaultBackAction={DT,Back}; compiled+saved.
- ✅
BP_SL_PlayerController—PauseAction=IA_SL_UI_Pauseset; compiled+saved. **PauseMenuClassstill
None** — blocked on WBP_SL_PauseMenu's tree existing.
- ✅ WBP shells created in
/Game/SystemLink/UI/Menus/:WBP_SL_TestScreen+WBP_SL_PauseMenu(both
parented to USLScreenWidget, verified) + WBP_SL_BoundActionButton (parented to CommonBoundActionButton — the action-bar entry widget; needs a TextBlock named exactly Text_ActionName added, then it's the Action Button class both action bars reference). Widget trees are empty — manual authoring next (confirmed the bridge can't build widget trees: no WidgetTree type / widget_tree prop exposed to Python).
Button assets — DONE (bridge, 2026-07-06→07): discovered the plan's "add USLButtonBase" step was a hole — the raw C++ class has Style=None + no label, so it renders blank. Resolved by migrating the real style library from the 5.6 project (C:\3D-DEV\HaloProject\SystemLink, UE 5.6 → 5.7):
- ✅ Full style kit migrated to
/Game/SystemLink/UI/Styles/—Buttons/(8: Clear/Small/Medium/Large/XL/
XXL/Row/Rotator) + Text/ (13), plus dep tree: Materials/ (11), Textures/ (4), Microgramma font in UI/Fonts/, UI sounds in UI/Sounds/. Copied 34 files → editor auto-completed the closure (Rotator arrow materials my grep missed) → relocated into /Game/SystemLink/UI/ via ref-safe rename. Verified: brushes resolve halo-gradiant-2/MI_UI_BodyBackground, text resolves Microgramma; no redirectors/leftovers. The 3 "null base" styles (Clear/Row/Rotator) are intentional (transparent / ROUNDED_BOX tint fills).
- ✅
WBP_SL_Button(/Game/SystemLink/UI/Menus/) —USLButtonBasesubclass,Style=Style_ButtonMedium.
Placeholder CBS_SL_MenuButton deleted. Manual step left: author its tree (CommonTextBlock named exactly ButtonText, centered — C++ binds that name; per-instance caption = ButtonDisplayText). Recipe in MenuSystemPlan.md §5.0a. Delegated: Docs/CodexHandoff_WidgetTrees.md is a computer-use handoff spec for building all four widget trees (WBP_SL_Button + BoundActionButton + PauseMenu + TestScreen).
⚠ Migrated assets are UE 5.6→5.7 upconverts — not yet committed; git-add the new Content/SystemLink/UI/
paths deliberately (stay away from git add -A per the screenshots note).
#Phase 0b — pause menu (controller menu pipeline) — status 2026-07-10
Goal: open a menu with Start, navigate fully on a gamepad, A confirms / B closes. Proof that the CommonUI foundation works so real menus (Main/Settings/Lobby) can build on it.
Not about pausing. "Pause menu" = the Start-opened overlay; it only freezes the game standalone/PIE
(bPauseGameWhileActive), networked it's a pure UI overlay (decision #4). Title reads "PAUSE MENU" anyway.
DONE (this session, 2026-07-09/10):
- ✅ Pause key fires —
IA_SL_UI_Pausemapped inIMC_Default(Start + P). The "added" claim was false;
fixed + verified (footgun: bridge IMC writes can miss the 5.7 default_key_mappings.mappings).
- ✅
WBP_SL_Button—ButtonTextlabel; angled Halo style (Style_ButtonMedium); a Border DMI wired
(Get Dynamic Material on the Border, target = Border) for the focus visual. Docs/ButtonWidget.md.
- ✅
WBP_SL_PauseMenu— title "PAUSE MENU" + 3 styled buttons (Resume · Settings · Exit);
bPauseGameWhileActive=true. Initial focus via a Get Desired Focus Target graph override (the AutoFocusWidget picker reverts/crashes — footgun 6.29; the field is now C++-only).
- ✅
PauseMenuClass=WBP_SL_PauseMenuset onBP_SL_PlayerController. Menu opens + displays in PIE.
- ✅ Selection reverted to Hovered for focus highlight (select-on-focus is sticky — footgun 6.31).
⚠ Pending C++ rebuild (3 changes, uncommitted):
ASLPlayerController::RestoreGameInputMode— restores game input/viewport focus when the last menu closes
(footgun 6.30; else the game is dead after close).
USLScreenWidget::ValidateCompiledWidgetTree— compile error if a screen has noGet Desired Focus Target.
USLCommonActivatableWidget::AutoFocusWidget— un-exposed from BP (bareUPROPERTY()).
#Button glyphs ("A" on the tile) — 2026-07-15
DONE — the "A" glyph now renders in PIE on mouse hover. WBP_SL_ButtonTile showed the glyph in the UMG designer but never at runtime. Two independent blockers; full writeups in UISystem.md §6.32 / §6.33.
IMC_SL_UIwas never applied (asset registry: zero referencers — orphaned). CommonUI resolves glyphs- verified via bridge read-back).
via QueryKeysMappedToAction, which only sees contexts applied right now → no key → NoBrush → UpdateActionWidget collapses itself, silently. Fix (C++, built): new ASLPlayerController::UIInputMappingContext, added/removed inside Push/PopGameplayInputSuppression so it's live exactly while a menu owns input. BP_SL_PlayerController.UIInputMappingContext = IMC_SL_UI (set
⚠ It must NOT be applied during play. First attempt applied it persistently at priority 1 and
silently broke jump/interact/cycle-weapon/sidearm/grenade — UI actions default to bConsumeInput=True
and share keys (Confirm=A/Space vs Jump=A/Space, NextTab=E/RB vs Interact/CycleWeapon, PrevTab=Q/LB vs
SidearmMode/Grenade). Caught by reading, not testing. → §4.2 known-limit note.
- The glyph fallback is hover-gated → dead on gamepad.
UCommonButtonBase::UpdateInputActionWidgetonly
reaches the default-click-action branch via IsHovered() — and SCommonButton sets bHovered solely from OnMouseEnter/OnMouseLeave, so focus never counts. FIXED in C++ — PIE-VERIFIED ✅ 2026-07-15: USLButtonBase now overrides HandleFocusReceived/HandleFocusLost (the internal Slate button's focus delegates — the only reliable signal) + UpdateInputActionWidget, re-pushing the same ICommonInputModule::GetSettings() click action the base would use, keyed off focus. Deliberately NOT via TriggeringEnhancedInputAction — that registers competing A bindings resolved by registration order + reachability, not focus → A would always click the first tile. → §6.33.
Glyphs are gamepad-only, and the command bar is HIDDEN on KBM (final decision 2026-07-20). Only CD_SL_Xbox (input_type=GAMEPAD) is registered. Rather than show keyboard glyphs, the whole command bar is hidden when the active device is mouse/keyboard: WBP_SL_CommandBar binds CommonInputSubsystem::OnInputMethodChanged (+ reads GetCurrentInputType on Construct for the initial state) and collapses its prompts unless the device is Gamepad. (Interim step, now REVERTED: we briefly authored a CD_SL_KBM White key-cap set + registered it in DefaultGame.ini; deleted once the bar was hidden on KBM — the glyphs would never render. DefaultGame.ini back to a single +ControllerData.) Recipe: Docs/CommandBarBackButton.md §"Gamepad-only visibility". PIE gate: open menu on mouse → bar hidden; tap pad → bar + Ⓐ/Ⓑ appear.
- Sticky focus highlight — all tiles stayed lit. FIXED + PIE-VERIFIED ✅ 2026-07-15 (BP). NOT sticky
selection (§6.31): selectable/toggleable/bShouldSelectUponReceivingFocus are all False and Style_ButtonTile's brushes are transparent — the visual is the Border DMI via SetBorderFocus. The graph looked symmetric but paired On Focused (UCommonButtonBase) with On Focus Lost (UUserWidget — has an In Focus Event pin), which never fires because focus is forwarded to the inner SCommonButton. Fix: swapped for On Unfocused (CommonButton's real partner). → §6.34.
Phase 0b controller pipeline is PROVEN ✅ (2026-07-15). Start opens → focus lands on Resume → D-pad/Tab
nav moves focus with exactly one tile lit → the "A" glyph follows focus on a gamepad → gameplay input
(grenade et al.) still works during play. The CommonUI foundation is validated; real menus can build on it.
The designer preview is not evidence. UCommonActionWidget::GetIcon() has an editor-only branch that
renders DesignTimeKey and bypasses the whole runtime path. It showed a perfect "A" the entire time both
blockers were live. Validate prompt glyphs in PIE only. → §6.32.
✅ REBUILT + PIE-VERIFIED (2026-07-15): the suppression-scoped wiring is live and tested — the "A" glyph renders in the pause menu and grenade/gameplay input still works during play (the regression check the first gate missed). Both halves of §4.2 confirmed on the same build.
NEXT — gamepad focus glyph (blocker 2): drive the action widget from focus on USLButtonBase (set the enhanced input action on focus received, clear on focus lost). Do NOT just set TriggeringEnhancedInputAction on the tile: that registers competing A bindings resolved by registration order + reachability, not focus → A always clicks the first tile. → §6.33.
Focus highlight — RESOLVED ✅ (2026-07-15). The answer to "which event drives the Border DMI on gamepad": On Hovered + On Focused → set, On Unhovered + On Unfocused → clear. All four are UCommonButtonBase events bound to the internal Slate button, so they fire for mouse, gamepad and Tab alike. CanSafelyRouteCall gates them but was never the problem — the bug was pairing On Focused with UMG's On Focus Lost (§6.34). Selection + UCommonButtonGroupBase is not needed and would reintroduce §6.31's stickiness.
#Bound action bar ("command bar") — ⚠ SUPERSEDED 2026-07-20 (kept for MenuKit reference)
This whole dynamic-bar section is superseded. Decision 2026-07-20: hardcode Back + Accept for the
game (Docs/CommandBarBackButton.md); the dynamicCommonBoundActionBarbelow is now a MenuKit-product
upgrade, not active work. WBP_SL_BoundActionButton is already fully built (see the NEXT MILESTONE section).
The text below is retained only for when the MenuKit dynamic-bar task is picked up.
(historical) Bound action bar — was IN PROGRESS 2026-07-15
| Goal: the strip showing `A Select | B Back`. Back is the priority. Note B/Esc already closes the menu |
|---|
(USLScreenWidget ctor sets bIsBackHandler) — what's missing is the bar displaying it.
DONE (C++, built ✅, PIE-UNTESTED): USLScreenWidget ctor now also sets bIsBackActionDisplayedInActionBar = true. It defaults to false in the engine, and it is a separate opt-in from bIsBackHandler — that's why back worked while staying invisible. Every SL screen is closeable, so every SL screen advertises it. → §6.35(a).
REMAINING (editor — state not yet inspected; editor was closed):
WBP_SL_BoundActionButtontree — needs BOTH aUCommonActionWidgetnamed exactly
InputActionWidget and a UCommonTextBlock named exactly Text_ActionName.
⚠ The old note in this file said "needs a TextBlock named Text_ActionName" — **incomplete and
actively misleading.** ALL of UCommonBoundActionButton::UpdateInputActionWidget() is wrapped in
if (InputActionWidget), and theText_ActionName->SetText()call is INSIDE it → no glyph widget means a
blank button, text and all. → §6.35(b).
- A
UCommonBoundActionBarwithActionButtonClass = WBP_SL_BoundActionButton(missing class = compile
error, so this link is self-enforcing). It does NOT need to be in the same widget tree — it reads the local player's action router, so one bar in the shared layout serves every screen. Decide: shared layout vs. per-screen. → §5.3 (that section's "same widget tree" claim was WRONG and is now corrected).
- Display name —
IA_SL_UI_Back.ActionDescription(data-driven, preferred) or
OverrideBackActionDisplayName per screen. Empty = correct glyph, blank label. → §6.35(c).
- PIE gate: open pause → bar shows
B Back(gamepad) → B closes. Expect no glyph on KBM — intentional,
there's no CD_SL_KBM (the text should still show).
NEXT — hook up the button actions:
- Resume — ✅ DONE (wired by Beepers):
OnClicked→ Get Owning PC → CastSLPlayerController→
Close Pause Menu.
- Settings → stub for now (push
WBP_SL_Settingsonce it exists) — or leave unwired.
- Exit → leave match / quit. Route through a
USLModalWidgetconfirm ("Leave match?") — see §confirm flow.
The old "then rebuild (the 3 C++ changes)" note here is obsolete — those landed, were PIE-verified, and
are committed in 9ea2dd5a along with the glyph/focus fixes.
#Monetization strategy + MenuKit extraction plan — 2026-07-16 (planning session, no code changes)
Strategy decided (memory project_fab_monetization): goal = passive income → work for self. Stack:
- POC: "MenuKit" — extract the CommonUI menu framework into a standalone Fab plugin. Byproduct of
menu work already underway; POC metric = live listing + first sale + pipeline knowledge, NOT revenue.
- Main bet: "Console-Ready UI with CommonUI" course (after POC ships). 3. Multiplier: devlog from
the footgun docs. 4. Later flagship: multiplayer GAS FPS template. Market research: Fab averages ~$1,200/publisher/yr; menus/icons/FP-kits all crowded — specialized long-tail + deep docs is what sells. Gemma (local Ollama gemma4:12b, installed + verified today) = bulk text drafts only, never UE code.
Plan drafted: Docs/MenuKitExtractionPlan.md. Coupling audit done — the UI kit has exactly 2 game couplings (SLPrimaryGameLayout.cpp:16 HUD cast → layout subsystem; SLCommonActivatableWidget.cpp:109 controller cast → UMenuKitInputComponent). Phases A–D + gates; open decisions §9 (name/prefix, free-lite tier, settings persistence, license header, engine floor).
AI-native wedge (plan §11): menus defined in a declarative text manifest (UMenuManifest) + a generator that builds the WBP widget trees — so any AI assistant (or a human) can author menus as text. No Fab menu competitor has this; general AI copilots (Ludus, Ultimate Engine CoPilot) are a funded crowded race we deliberately do NOT enter — we make products their users' AI can operate.
Widget-tree generator spike — PASS ✅ (bridge, live editor): script CAN build widget trees: new_object(cls, outer=wt, name=…) constructs anything (CommonUI classes + SL bases included), PanelWidget.add_child IS exposed (returns real slots), compile+save+linkage verified. Sole wall: WidgetTree.RootWidget is protected (no script read/write) and fresh 5.7 WBPs are rootless → dodge: duplicate a seed template that has a root, build under it. Full matrix: plan §11.4; memory feedback_bridge_widget_tree_property_edit updated (the old "bridge can't build trees" belief is superseded; Docs/CodexHandoff_WidgetTrees.md is retired — trees are script-buildable now).
🧹 Scratch assets pending cleanup:/Game/Dev/Spike/WBP_MK_SpikeGen+WBP_MK_SeedTest(the
latter shows "GRAFTED BY SCRIPT" in its HorizontalBox as visual proof). Delete after inspection.
Sequencing unchanged: finish the bound action bar (PIE gate above) FIRST, then MenuKit Phase A.
#Merged history (grenade-refinement, PR #28) — kept for reference below
⚠ The items below are MERGED. Left in place as design/RCA reference; not active work.
⚠ All C++ below is UNCOMMITTED and needs a rebuild. When committing, stage specific paths (Plugins/…, .gitignore, Docs/*.md) — NOT git add -A — because Docs/Screenshots/Site was just un-ignored (~286 MB of local screenshots would otherwise sweep in).
- Per-drop launch variance —
ASLPickupBase::LaunchPickupnow randomizes yaw + pitch (LaunchPitchSpread,
±15°) + speed (LaunchSpeedVariance, ±25%) per drop, so death-drop piles scatter instead of arcing in lockstep. On the base class → applies to ALL pickups (weapons, sidearm, grenades, ammo, health).
- Thrown grenade chains into pickups — extracted
ASLGrenadePickup::ChainDetonateNearby(...)static;
ASLGrenadeProjectile::Explode now detonates grenade pickups within its DamageOuterRadius. Pickup→pickup chain unchanged (ChainDetonationRadius). Cascades + kill-credit-to-thrower both work. See GrenadeSystem.md.
- Pistol ADS (auto-on-draw zoom) — SWELL FIXED via first-person rendering (2026-07-05).
ASLCharacterBasector:FPMesh/FPWeaponMesh/FPSidearmMesh→FirstPersonPrimitiveType::FirstPerson.ASLPlayerCharacterctor:Camera->bEnableFirstPersonFieldOfView = true,FirstPersonFieldOfView = 95Config/DefaultEngine.ini:r.FirstPerson.Enabled=True(read-only cvar → needs an editor restart).
USLWeaponDataAsset::ADSFieldOfView (0 = off). ASLPlayerCharacter::TickADS lerps the world camera FOV between base and the sidearm's ADSFieldOfView while the pistol is drawn, timed to SidearmDrawDuration (in) / SidearmHolsterDuration (out). Local-only. Reads the sidearm's OWN data (not GetActiveFireWeaponData, which flips on holster). The viewmodel-swell problem is SOLVED. Narrowing the world FOV used to magnify the FP viewmodel too (the pistol ballooned/detached at 65 — it looked like a socket bug, wasn't). Fix = UE 5.5+ native first-person rendering: the FP meshes render at a separate fixed FOV so they don't zoom with the world. Now in C++:
The sidearm mesh missing this flag was the actual bug — pistol = sidearm = the only unflagged FP mesh.
(matches base FOV so arms are unchanged at rest).
PIE-verified 2026-07-05 (screenshot): world zooms, pistol stays seated in hand, Substrate + VSM clean. C++ compiled ✅. The abandoned counter-scale journey is now just a footgun record in Docs/ADS.md; the dead ADSScaleCompensation member stays stripped. BP_SL_MasterChief CDO overrides CLEARED — the spike's redundant per-instance deltas were dropped by a force-save once the C++ archetype matched (bridge), so C++ is the single source of truth (.uasset re-saved). Option B (kept, orthogonal to the swell fix): the FP sidearm inherits the arms' scale (driven by the equipped MAIN weapon's FirstPersonMeshScale), so ApplyFPWeaponObstructionScale divides that baseline back out → sidearm world scale = DA_SL_Pistol.FirstPersonMeshScale × obstruction, independent of the primary. Tune pistol on-screen size via DA_SL_Pistol.FirstPersonMeshScale (live). Editor step: set DA_SL_Pistol.ADSFieldOfView (~65; base FOV is 95).
- Pistol muzzle flash — socket fix + FP-render helper (2026-07-05, C++ compiled). Two bugs, both from the
- Muzzle socket misplaced.
SK_Pistol'sMuzzlesocket (parented to theBarrelbone) was at - FX didn't inherit first-person rendering. A muzzle flash spawned via Niagara
Spawn System Attached
first-person-rendering switch surfacing pre-existing issues:
(12, 0, 0) — authored as if the barrel pointed +X, but this mesh is +Y-forward (geometry runs Y:[-5..19], X only ±2.6), so the flash spawned ~12 cm out the side near mid-body. Corrected via bridge to loc (0, 18.5, 8), yaw 90 (X centred, Y at the barrel tip, Z ~bore height; yaw aligns socket-forward +X down the barrel +Y). ⚠ Z and yaw are ESTIMATES — verify visually in the SK editor + test-fire the flash direction. SK_Pistol.uasset saved.
does NOT inherit the FP mesh's FirstPersonPrimitiveType, so under ADS (world FOV 65 vs FP FOV 95) it desyncs from the gun — and the pistol always fires while aiming, so it's always visible. Fix = new
USL_BlueprintLibrary::SpawnSystemAttachedMatchingView (Category `SystemLink | FX`): drop-in for |
|---|
Spawn System Attached that walks up the attach chain and flags the FX with the parent's FP mode (FP → FP, TP/world → None). No per-weapon data bool — correct by construction; reusable for shell eject / tracers. ⚠ BP STEP: in BP_WeaponLibrary, swap Spawn System Attached → Spawn System Attached Matching View (delete any manual Set First Person Primitive Type node).
.gitignore— un-ignoredDocs/Screenshots/Site/so the curated gallery is version-controlled going
forward (the root Docs/Screenshots/ stays ignored).
Test after rebuild: die with several items → scatter; throw a grenade near a pickup / into a pile → chain; draw the pistol → smooth FOV zoom (whole viewmodel magnifies uniformly, pistol stays in hand), holster → zoom back; fire the pistol while aiming → muzzle flash sits on the barrel tip and tracks it at the zoomed FOV.
RESUME HERE (2026-07-05): ADS viewmodel swell SOLVED + productionized + compiled ✅ via UE native
first-person rendering (item 3). CDO spike overrides cleared; C++ is the single source of truth. Then found +
fixed the pistol muzzle flash (item 4): socket was misplaced on the wrong axis (fixed via bridge, estimate)
and the flash didn't inherit FP rendering (new SpawnSystemAttachedMatchingView helper — compiled).
Uncommitted, all compiled:SLCharacterBase.cpp(mesh FP flags),SLPlayerCharacter.cpp/.h(camera FP FOV +
earlier ADS/Option-B),SLWeaponDataAsset.h,SL_BlueprintLibrary.cpp/.h(FX helper),Config/DefaultEngine.ini
(r.FirstPerson.Enabled=True),BP_SL_MasterChief.uasset(cleaned),SK_Pistol.uasset(muzzle socket).
NEXT (all editor, no more C++ pending):
1. BP node swap —BP_WeaponLibrary:Spawn System Attached→Spawn System Attached Matching View
(delete any manual Set First Person Primitive Type node).
2. Verify the muzzle socket — openSK_Pistol, nudge theMuzzlesocket Z/yaw so the flash sits on the
barrel tip and points forward (current (0,18.5,8)/yaw 90 are estimates).
3. PIE-test: fire pistol while aiming → flash on the barrel, tracks under ADS; ADS still correct off the C++
path; then the other refinements (scatter, grenade→pickup chains).
4. Optional: Footgun line (attached FX to FP meshes → use SpawnSystemAttachedMatchingView) + note the
muzzle-socket fix in Docs/ADS.md.
5. Commit — stage SPECIFIC paths, NOTgit add -A(the un-ignoredDocs/Screenshots/Site/would sweep
in ~286 MB). Paths: the C++ files above,DefaultEngine.ini,BP_SL_MasterChief.uasset,SK_Pistol.uasset,
Docs/ADS.md,Docs/CurrentFocus.md,Docs/Footguns.md.
#Where We Left Off
2026-07-02 — grenade Increment 1 done + committed (9175298); now on grenade extras + a TP LHIK Control Rig. HUD grenade indicator DONE, BUG-023 verified (2-player PIE), committed. Since then, two grenade tweaks + one rig task are IN PROGRESS (⚠ C++ UNCOMMITTED, needs rebuild):
- Per-grenade death drop —
ASLCharacterBase::DropGrenadesOnDeath()now spawns ONE pickup per grenade
(GrenadeAmount = 1 each) instead of a single pickup carrying the count. Existing LaunchPickup yaw-spread + pickup-vs-pickup ignore already handle scatter / no mid-air freeze. Watch: collect-sound spam on a tight pile.
- Shootable grenade pickups (+ chain reactions) — NEW
ISLDamageableinterface USLGameplayAbility_Fire::ExecuteFire_ListenServerHost) now route non-pawnECC_WeaponTracehits to
(Public/Interfaces/SLDamageable.h); the two authoritative fire sites (USLWeaponsComponent::Server_ProcessShots
ReceiveWeaponDamage. ASLGrenadePickup implements it: accumulates weapon damage → detonates past DetonateDamageThreshold (default 1) → full grenade blast credited to the shooter's ASC → chain-detonates other pickups within ChainDetonationRadius (default 350) on a short random delay (recursion-guarded via bDetonated). Blast logic extracted to a shared static ASLGrenadeProjectile::ApplyGrenadeExplosion(...) so projectile + pickup detonate identically. Pickup gains a small HitCollision sphere that blocks ONLY ECC_WeaponTrace (so shots register; doesn't affect the arc). New tunables on BP_SL_GrenadePickup_Frag: DetonateDamageThreshold, ChainDetonationRadius, HitCollision radius. Melee does NOT detonate (fire only — easy to add later via the same interface call). Build note: first build failed C2679 (incomplete UAbilitySystemComponent for the TWeakObjectPtr assign) → fixed with #include "AbilitySystemComponent.h" in SLGrenadePickup.cpp. Needs a clean rebuild + 2-player PIE test (shoot → detonate, kill-credit, chain).
Two tasks queued (task list #1/#2), editor-dependent:
- Rename
USLGrenadeIndicator::Initialize→InitializeIndicator— clears C4263/C4264 shadowing of
UUserWidget::Initialize() (our feedback_uobject_function_name_clashes pattern). Verify BP callers via bridge first; update caller SLHUDWidget.cpp:103. Needs a rebuild.
- Auto-generate
CR_MC_TP_LeftHandIK— Control Rig to REPLACE the FABRIK LHIK on the TP body. Two Bone IK
on upperarm_l→lowerarm_l→hand_l + pole vector, exposed as CR vars (LeftHandTarget/PoleVector/Alpha) the TP ABP feeds from LHIK_TP_Snapshot. Asset-only (no rebuild). Via bridge.
2026-06-23 — grenade Increment 1 is ~90% done. Most of the 2026-06-19 "remaining" checklist has since been built (assets are in the tree, uncommitted). Recon this session reconciled the doc to reality.
BUG-024 (2026-06-24) — equip sound heard map-wide. Remote players' equip sound played at full volume
everywhere (most visible on respawn). RCA (corrected): the equip GameplayCueNotify_Burst plays
Rifle_Raise_Cue(which HAS attenuation) withDoNotAttach, so it spawns atCueParameters.Location—
which ExecuteEquipCue never set → played at world origin (0,0,0), within earshot of the whole compact
TestMap. (NOT a Play Sound 2D node — there is no sound node; first guess was wrong.) **C++ fix (sufficient,
needs rebuild):ExecuteEquipCuenow setsCueParams.Location/Normal. Robustness DONE:** equip cues'
burst-soundAttachPolicy→ AttachToTarget (both AR + Shotgun, via bridge). Full RCA:BugTracker.mdBUG-024. Also new reusable
SA_Default3Dattenuation (/Game/SystemLink/Audio/Attenuation/) for future 3D sounds.
Audio audit (2026-06-25): swept all Play Sound 2D (C++ + BP). C++ clean (sidearm draw/holster gated).
BPs:WBP_SL_RespawnOverlay(UI, fine),BP_SL_MasterChief(correct SC_/SC_TP_ local-2D + attenuated-3D
pairs — fine). One finding →BP_GA_SL_Grenade_Throw:SC_FragThrowwas played 2D in a Local-Predicted
ability → listen-host heard every player's throw. Decision: thrower-only feedback (gate local). Done:
cleared the now-meaninglessSA_Default3DoffSC_FragThrow(bridge). PENDING BP STEP: gate the throw's
Play Sound 2Dnode behindIs Locally Controlled(Branch, True → play) so only the thrower hears it.
Grenade C++ — DONE + committed (a308b5e, then polished in ee1109a): tags, USLGrenadeDataAsset + ASLGrenadeProjectile, ASLCharacterBase grenade count (GrenadeCount + CurrentGrenadeData, both replicated) + OnGrenadeCountChanged delegate + ThrowActiveGrenade() + loadout DefaultGrenade + controller GrenadeAction. ee1109a MP polish: BeginGrenadeThrow() schedules the authoritative spawn on a character world timer (ThrowReleaseTime) — fixes BUG-023 (clients couldn't throw: the spawn was on a Events.Grenade.Release anim notify and the server doesn't tick a remote client's montage). Plus cosmetic SpinMovement, Halo-style accelerating FuseLight, grenade blocks the Pawn channel (bounces off players, thrower excluded at launch).
Grenade — built but UNCOMMITTED (the bulk of the old checklist):
BP_GA_SL_Grenade_Throw(ability graph) — items 1+2: exists, granted inAS_AbilitySet_Default.
GC_SL_Grenade_Frag_Explosioncue — item 4: exists;GameplayCue.Grenade.Frag.Explosionregistered.
- FP+TP throw montages (
AM_MC_FP_Frag_Throw,AM_MC_TP_Frag_Throw),DA_SL_Grenade_Fragfilled — item 3.
- Real frag mesh (
Frag/SM_Modern_Weapons_Grenade_01+ materials) + realBP_SL_GrenadeProjectile_Frag—
item 7. Bounce/explosion audio (SC_FragExplosion, SC_Frag_Bounce, tinks).
IMC_Defaultmapped (item 6),DA_DefaultLoadoutupdated.
- Beyond plan:
GE_SL_Grenade_Cooldown,CS_ExplosionShakecamera shake.
- Uncommitted C++ refinement (
SLGrenadeProjectile.*,SLGrenadeDataAsset.h): replicates the whole
GrenadeData (was just FuseDuration) so clients sync spin + blink; new cosmetic BounceSound field + OnProjectileBounced. ⚠ Needs a rebuild to go live (editor/Live-Coding won't surface the new field).
Grenade pickup + death-drop — C++ DONE (2026-06-23), ⚠ pending rebuild + BP:
ASLGrenadePickup(World/SLGrenadePickup.h/.cpp) — auto-collect pickup mirroringASLAmmoPickup. Tops
up the character's count for its GrenadeData type (clamped to MaxCount); ignores characters whose active type doesn't match or who are already full (left in world). No prompt — collects on overlap.
USLGrenadeDataAsset::DroppedPickupClass— which pickup BP to spawn on death (mirrors the weapon data's
field). Null = drop nothing.
ASLCharacterBase::DropGrenadesOnDeath()— spawns ONE pickup carrying the full current count, launches it
with the same DropSpawnOffset/DropLaunchPitch as weapon drops, then zeroes the count. Called from USLGameplayAbility_Death right after DropAllWeaponsOnDeath().
- Editor steps: (a) rebuild (new class + UPROPERTYs — Live Coding won't surface them); (b) create
GrenadeAmount); (c) setDA_SL_Grenade_Frag.DroppedPickupClass = BP_SL_GrenadePickup_Frag; (d) test:
BP_SL_GrenadePickup_Frag (subclass ASLGrenadePickup, set bounce mesh + GrenadeData=DA_SL_Grenade_Frag
place one → walk over → count rises (capped at MaxCount, ignored when full); die holding grenades → a pickup drops carrying the count → collect it back.
Grenade — Increment 1 essentially DONE ✅ (2026-07-01):
- HUD grenade-count widget — DONE ✅.
WBP_SL_GrenadeIndicator(USLGrenadeIndicator subclass) binds
OnGrenadeCountChanged; icon + count live. Gotcha that bit: the widget instance in WBP_SL_HUDWidget must be named EXACTLY GrenadeIndicator (the BindWidgetOptional name) — it was named WBP_SL_GrenadeIndicator → bound to null → BIE never fired, no compile warning. See memory feedback_bindwidgetoptional_silent_noop.
- Rebuild — done (MP tested against live code).
- BUG-023 throw — VERIFIED ✅ via 2-player listen-server PIE: host AND client both spawn a real grenade;
both observers see both projectiles + explosions. BugTracker.md BUG-023 marked verified.
- REMAINING: radial-damage falloff check (full inside
InnerRadius→ 0 atOuterRadius) is the one
test-checklist item not yet ticked. Everything else on the Increment 1 checklist passes.
⚠ Build env note: one UBT build flagged a banned MSVC toolchain (14.40–14.43 → install 14.44.35207).
Seen once; if a full "close editor + build" fails on the toolchain, install the recommended MSVC component.
The dated lists further down are historical context from earlier sessions.
Sidearm — done & committed:
- C++ foundation: tags,
FP/TPSidearmMesh, replicatedSidearmWeapon,SetSidearmWeapon,
LoadDefaultLoadout spawns DefaultSidearmClass, SLAnimState.bIsSidearmActive, loadout/data-asset fields.
BP_GA_SL_SidearmMode(hold-LT draw, pure tag manager) + LT input wiring.
- Mesh visibility via
USLWeaponsComponent— now poll-driven inBuildAnimSnapshots(BUG-014: the
one-shot tag-event path was unreliable on the listen-server host for proxies; see feedback_gas_tag_callback_patterns Rule 3).
- Sidearm Blend AnimGraph node — custom C++ node (
FAnimNode_SidearmBlend+ new
SystemLinkCoreEditor module) compositing sidearm-over-lowered-primary at clavicle_r, gated by Is Sidearm Active. Committed + pushed (2234ce7). Doc: SidearmMode.md §4.1.
- TP sidearm ABP worked out (idle / run-walk / crouch) using the node.
Sidearm — shooting (VERIFIED ✅ 2026-06-09):
- C++ (
GetActiveFireWeaponindirection + actor-owned ammo, option A) acrossUSLWeaponsComponent+
USLGameplayAbility_Fire — committed. Primary fire unchanged when no sidearm is drawn. Doc: SidearmMode.md §8.1; memory project_sidearm_fire_routing.
- Editor tasks 1–4 done via the Unreal MCP bridge:
BP_GA_SL_Pistol_PrimaryFire
(RequiredWeaponClass=Pistol actor, ActivationRequiredTags=SidearmActive), AR+Shotgun primary blocked while SidearmActive, pistol fire ability granted via AS_AbilitySet_Default (sidearm is a separate always-carried slot — never runs through GrantWeaponAbilities, so it MUST be set-granted), DA_SL_Pistol.PrimaryFireMode filled (single-shot, 400 RPM, 26 dmg, 12 ammo, Muzzle socket, no AmmoDecrementEffect).
- PIE-verified (listen server, 2 players): single-shot fire, independent sidearm ammo, and the
host-validates-client-damage RPC path (GetActiveFireWeaponData() resolves the sidearm server-side). Committed as d5e8ddf.
Weapon Action Lock — States.Weapon.Busy (done 2026-06-13). GAS-native lock so exclusive actions block other weapon actions. Native tag added (SLTags.h/.cpp) + full BP matrix wired via the bridge (fire/melee/sidearm/equip × 13 abilities). Exclusive actions own Busy; everything blocks Busy; switching also blocks Firing (fixes "switch to pistol mid-AR-fire → pistol full-autos"). Equip OWNS but does NOT block Busy (FP+TP equip run concurrently — blocking would mutually cancel them). Full spec + matrix + caveats: Docs/WeaponActionLock.md.
Pistol full-auto (BUG-018) — root-caused + fixed. Was the switch-mid-AR-fire path: the AR's full-auto fire ability is one looping activation that re-resolves GetActiveFireWeapon each iteration, so drawing the sidearm mid-loop fired the pistol at AR cadence. Fixed by the Firing block on SidearmMode (the AR loop owns Firing continuously, so the draw is blocked). No separate clean-draw full-auto bug — pistol reads GetActiveFireMode = SingleShot and fires once. Also fixed a latent bug: StopPrimaryFire used GetPrimaryFireMode() (equipped) instead of GetActiveFireMode(true) (active) → swapped to active (spam-tap fix). See BUG-018.
RefireLock split + sidearm action-lock fixes — DONE & PIE-verified ✅ (2026-06-13). All three C++ changes rebuilt and live: StopPrimaryFire uses GetActiveFireMode; RequestEquip blocks main-weapon swap/pickup while sidearm drawn; RefireLock split (native tag + RefireLockDuration field + fire-ability loose-tag management). BP re-wire done via bridge (SidearmMode + 6 equips block on RefireLock not Firing). DA_Shotgun.RefireLockDuration = 0.2 (PostFireDelay 0.5) — switch to pistol 0.2s after a shot while re-fire stays gated. Tested good, feels good. Full design: Docs/WeaponActionLock.md.
Design decisions (2026-06-14): Sidearm now drops on death as a pickup (supersedes BUG-013's "destroy"). Pistol pickup: same type → ammo refill on the sidearm; different → swap into sidearm slot, old drops as a pickup.
Drop-all-on-death + sidearm pickup routing — DONE in C++, ⚠ PENDING REBUILD (2026-06-14):
ASLCharacterBase::DropAllWeaponsOnDeath()drops allCarriedWeapons+ the sidearm (clears slots);
called from USLGameplayAbility_Death before OnDeathStarted (deterministic, authority).
ASLWeaponPickup::OnCollected/AcceptPickupbranch onWeaponData->bIsSidearm→ route to the sidearm
slot (same type = ammo via AddAmmo; different = SetSidearmWeapon, which drops the old). Data-driven, so placed AND dropped/death-dropped pistols route correctly.
DA_SL_Pistol.bIsSidearm= True (confirmed).DroppedPickupClass= None (needs the pistol pickup BP).
Rebuilt + wired (2026-06-14): C++ live. BP_SL_WeaponPickup_Pistol created (/Game/SystemLink/Pickups/Weapons/Pistol/, dup of Shotgun2) with WeaponActorClass = BP_SL_WeaponActor_Pistol; DA_SL_Pistol.DroppedPickupClass → it.
REMAINING (Beepers, editor):
- Set the pistol pickup's display mesh —
BP_SL_WeaponPickup_Pistolstill shows the shotgun mesh (SCS
component from the dup; not reachable via bridge). Set it to the pistol mesh (SK_Pistol) — unless the pickup shows the weapon actor's mesh at runtime, in which case it's already right (check on placement).
- Remove weapon-drop nodes from the
BP_SL_MasterChiefOnDeathStartedoverride — C++ drops everything
now; leftover BP drops are redundant no-ops. Keep any non-drop death logic (ragdoll/score/etc.).
- Test: die → all carried weapons + the sidearm drop as pickups; pick up same pistol → ammo refill;
different pistol → swaps into sidearm slot + old drops; pistol pickups route to the sidearm slot (not main inventory); placing BP_SL_WeaponPickup_Pistol in the level is collectable.
ACTIVE TASK — Pistol HUD (2026-06-15): sidearm ammo indicator — pistol icon + ammo count. C++ scaffolding landed (see "Sidearm HUD" below); BP authoring + data-asset icon are the remaining editor steps. Reticle swap on draw (OnSidearmActiveChanged, now edge-correct) is the sibling task — the indicator is persistent (keyed off OnSidearmChanged actor validity), while the reticle swap still keys off the draw/holster OnSidearmActiveChanged toggle.
Sidearm HUD — C++ done (2026-06-15): reuses the existing USLAmmoWidget (its SetWeapon(actor) binds the actor's OnAmmoChanged + pushes the initial value — the sidearm is an ASLWeaponActor, so no new widget class needed). USLHUDWidget gains an optional SidearmAmmoWidget (BindWidgetOptional) + an InitializeSidearmIndicator(Character) helper, called from ASLPlayerCharacter::InitializeLocalPlayerHUD alongside the ammo strip. Visibility is persistent — shown whenever the player has a sidearm (drawn or holstered), hidden only when there's no sidearm — driven off OnSidearmChanged actor validity. Icon comes from WeaponData->WeaponIcon via the new OnWeaponSet BIE on USLAmmoWidget. Remaining (editor): author the sidearm indicator BP (USLAmmoWidget subclass: Image + ammo Text, implement OnAmmoChanged + OnWeaponSet), bind it as SidearmAmmoWidget in the HUD BP, set DA_SL_Pistol.WeaponIcon. See Docs/SidearmMode.md §12.
OPEN THREADS (pick up here — as of 2026-06-11):
- ⚠ REBUILD PENDING — 4 uncommitted C++ changes, not yet compiled. Close editor → build → reopen
ASLCharacterBase::GetActiveWeaponMesh()—BlueprintPure, view+slot-aware (FP/TP × sidearm/main).USLWeaponsComponent::OnSidearmActiveChanged(bool)—BlueprintAssignable, edge-detected fromFSLWeaponViewDriverBaseanim-layer swap fix (BUG-016) — targetedUnlinkAnimClassLayersinsteadSLGameplayAbility_Melee::GetWeaponData()→GetActiveFireWeaponData()(pistol-whip, #2).
(Live Coding won't register the new UFUNCTIONs/delegate for BP):
Attach fire FX to this. ApplyPistolFireEffects already works; optionally simplify it onto this.
UpdateSidearmMeshVisibility. For the reticle swap (#5).
of LinkAnimClassLayers(nullptr), so swapping the main weapon no longer drops the sidearm layer.
(Note: the controller's sidearm-redraw change was reverted to Started — net-zero diff there.)
- Pistol-whip melee (replaces auto-holster). Melee while sidearm drawn = pistol-whip: pistol stays
out, swing uses the pistol's melee values. C++ done + rebuilt (#1). EDITOR STEP — DONE ✅ (2026-06-13): verified BP_GA_SL_Melee.CancelAbilitiesWithTag is empty (no SLTags.Abilities.SidearmMode), so melee no longer cancels sidearm mode. Full tag config confirmed: AbilityTags=Abilities.Melee, ActivationOwnedTags=States.Character.Meleeing, ActivationBlockedTags=States.Character.Dead+Meleeing. ANIM (remaining): pistol-whip melee state gated on bIsMeleeing && bIsSidearmActive (Always Reset on Entry + Inertialization + MeleeImpact notify); put the DisableLHIK curve on the pistol melee clip so off-hand IK releases during the swing (Docs/LeftHandIK.md). Doc: SidearmMode.md top callout.
- Cues — DONE ✅. Pistol-specific cues live: tags
GameplayCue.Weapon.Pistol.PrimaryFire/.Impact
registered, GC_SL_Pistol_PrimaryFire/_Impact tagged, DA_SL_Pistol Fire/ImpactCueTag repointed (cleared the AR duplicate-tag collision — BUG-015). FP/TP/local/non-local all route through ApplyPistolFireEffects in BP_WeaponLibrary. PIE-verified. PostFireDelay = 0.2 placeholder (tune to the fire clip; AM_Pistol_Fire is 0.667s).
- TP crouch-fire anim (task #6). FP fire anim DONE. TP: firing while crouched stands the character up
bIsFiringare onAnimStateSnapshot.
— needs crouch-aware Firing. Either a CrouchFiring state gated on bIsCrouched (crouch-fire clip), or upper-body-only fire via Layered Blend Per Bone (lower body keeps crouch, no extra clip). bIsCrouched
- Reticle swap (task #7) — DONE ✅ (2026-06-15). C++:
OnSidearmActiveChanged(edge-correct) +
USLWeaponsComponent::GetActiveReticleClass() (BlueprintPure: sidearm's PrimaryReticleClass while SidearmActive, else equipped's; falls back to equipped's when the active weapon has none) + SwapReticle no-ops on an unchanged class. BP wired in WBP_SL_HUDWidget (Initialize Variables / Bind to Equip Event / Bind to Sidearm Events groups): equip + OnSidearmActiveChanged + OnSidearmChanged all route through SwapReticle(GetActiveReticleClass(), GetOwningPlayer), so draw/holster AND pickup-swap-while-drawn all update correctly. DA_SL_Pistol.PrimaryReticleClass set. Verified working. Full reference: SidearmMode.md §13.
- Verify after build: BUG-016 (pistol anims persist through Shotgun↔AR swap) + pistol-whip melee feel.
- Later: sidearm draw is INSTANT —
SidearmDrawDuration/SidearmDrawingare unused scaffolding until
a draw anim exists (then gate fire on not-SidearmDrawing); HUD sidearm ammo indicator (bind SidearmWeapon->OnAmmoChanged); BUG-013 sidearm-on-death cleanup (still open).
Sidearm DRAW — DONE & feels good ✅ (2026-06-14). Full draw landed: gameplay windup + fire-block + observer-correct animation + None-guard. FSLAnimState has bIsSidearmDrawing + bIsSidearmHolstering, both PURELY COSMETIC anim drivers latched in BuildAnimSnapshots off the rising (draw) / falling (holster) edge of the replicated SidearmActive tag — so observers see them. Decoupled from the SidearmDrawing gameplay tag, which does the fire-block (owning client + server only).
- Gameplay:
BP_GA_SL_SidearmMode§3 windup graph (Commit-gated Add +OnEndAbilityRemove,
Should Replicate=FALSE, Is Valid(GetSidearmWeaponData) guard on the Delay duration); SidearmDrawing in BP_GA_SL_Pistol_PrimaryFire.ActivationBlockedTags; DA_SL_Pistol.SidearmDrawDuration=0.5.
- Observer fix:
bIsSidearmDrawingrising-edge latch offSidearmActive(heldSidearmDrawDuration,
fallback SidearmDrawCosmeticFallback 0.5s) — the loose tag doesn't replicate to proxies.
- New BP accessors on
USLGameplayAbility:GetSidearmWeapon()/GetSidearmWeaponData()(null-safe).
- Full spec:
Docs/SidearmMode.md§3 + §4.2.
HOLSTER — DONE ✅ (2026-06-15, cosmetic). Pose + mesh now both hold through the holster tail. Implementation (all cosmetic — gameplay returns to the primary the instant SidearmActive drops):
- Pose: the
Sidearm Blendnode'sIs Sidearm Activepin is driven by `Is Sidearm Active OR Is
Sidearm Holstering (OR node off Break SLAnim State) in ABP_MC_TP_Pistol — keeps the sidearm branch blended in during the tail (the node's bIsSidearmActive alone would crossfade it out over BlendTime`).
- Mesh: BuildAnimSnapshots now calls `UpdateSidearmMeshVisibility(bIsSidearmActive | bIsSidearmHolstering)` |
|---|
(SLCharacterBase.cpp:658) so the pistol stays visible until the holster anim ends instead of popping out.
- Duration is now per-weapon: new
USLWeaponDataAsset::SidearmHolsterDuration(default 0.5s, tooltip
flagged PURELY COSMETIC). The falling-edge latch reads it (SLCharacterBase.cpp:644), falling back to the character's SidearmHolsterCosmeticTime (0.3s) when no weapon/data resolves. Set DA_SL_Pistol.SidearmHolsterDuration to the holster-clip length (matches SidearmDrawDuration since the holster is the draw played in reverse).
- Draw + holster sounds (local-only, data-asset driven): new
USLWeaponDataAsset::DrawSoundand
HolsterSound (USoundBase, tooltips flagged PURELY COSMETIC / LOCAL-ONLY). Played via UGameplayStatics::PlaySound2D in BuildAnimSnapshots on the draw rising edge / holster falling edge, gated on IsLocallyControlled() — not replicated, observers don't hear them (the gate also prevents a listen-server host hearing remote players' sounds). Draw sound was MOVED out of BP_GA_SL_SidearmMode onto the data asset so the ability stays weapon-agnostic/reusable. Editor step: delete the draw-sound nodes from BP_GA_SL_SidearmMode, then set DA_SL_Pistol.DrawSound + .HolsterSound.
- Delegate-edge fix:
UpdateSidearmMeshVisibilitynow edge-detectsOnSidearmActiveChangedon the
gameplay SidearmActive tag (IsSidearmActive()), NOT the visibility flag — otherwise the reticle-swap delegate would fire ~0.5s late (at the end of the cosmetic tail) instead of when gameplay returns to the
| primary. Mesh visibility still uses `active | holstering`; the broadcast uses the tag. |
|---|
- ⚠ New UPROPERTYs (
SidearmHolsterDuration,DrawSound,HolsterSound) → **full rebuild + editor
restart** (Live Coding won't surface the fields), then set the data-asset values.
Holster currently reuses the draw clip in reverse and feels good. Optional later: a dedicated draw/holster ABP state pair (bIsSidearmDrawing / bIsSidearmHolstering flags + windup timing are already in place).
Side work (non-branch): the ClaudeFace repo has uncommitted Loom changes this session — per-model
substrate signature, Face retired, manual-posture minimum-dwell, session-start auto-open hook. See
Docs/WorkingWithClaude.md(2026-06-10 addendum) + memoryproject_claude_face.
Melee — fully done:
- Input,
BP_GA_SL_Melee, ability-set grant, fire-block tag — done.
- ABP melee state for AR + Shotgun + Unarmed (FP + TP), Always Reset on Entry, Inertialization.
MeleeImpactAnimNotify on FP + TP melee anims.
- C++ features: backstab multiplier,
MeleeImpactCueTag,OnMeleeWallHitBIE, camera shakes
(Swing/Hit/WallHit on weapon data asset — swing only plays on clean miss), timer-driven authority damage, C++-owned ability end timer, eye-origin sphere sweep.
- All melee values (damage, range, radius, backstab, shakes, timing) moved to
USLWeaponDataAsset.
- Unarmed-as-default-weapon:
DefaultUnarmedDataonUSLWeaponsComponent,GetEquippedWeaponData()
fallback, sway/lag/obstruction/scale all routed through it when unarmed.
FirstPersonMeshScalemoved from character to weapon data asset, re-applied on every weapon swap.
MuzzleSocketno-mesh warning fixed inResolveMuzzleLocation.
Weapon Clipping — done (FP): Docs/WeaponClipping.md. Optional leftover: AR FP ABP retract additive.
Respawn stuck-bug — FIXED. See RespawnSystem.md.
#Future-work doc also written this branch (not part of melee work)
Docs/MobilityAssistModule.md — full plan for Doom Eternal-style double jump + dash via an equippable Mobility Assist Module (MMA-3). Added as item #6 in Docs/Progress.md backlog. Not in scope for the melee-ability branch.
#What Was Done This Session (2026-05-19)
TP shotgun ABP — non-looping fire anim fix
- Symptom: shotgun fire animation in
ABP_MC_TP_Shotgun'sFiringstate played once on first shot, then never again on subsequent shots. AR equivalent worked fine.
- Root cause: non-looping asset players in state-machine states retain their time position across re-entries. After the first play, the asset player sits at t=end. Re-entering the state finds nothing left to play.
- Fix: enabled "Always Reset on Entry" on the
Firingstate. Asset player resets to t=0 on every state entry. Looping anims (AR, crouch) don't hit this because they wrap around continuously.
- Memory pointer:
feedback_animbp_state_reset_on_entry.md— this fix is documented for future state-machine work.
Inertialization node added — ABP_MC_TP_Shotgun AnimGraph: the Not Firing → Firing transition used Inertial Blend logic but had no Inertialization node downstream, producing a warning on quit. Single Inertialization node placed between the state machine output and the Layered Blend Per Bone.
FSLAnimState::bIsFirstPerson added
- New
UPROPERTY(BlueprintReadOnly)bool on the anim snapshot, sourced fromWeaponsComponent->IsFirstPersonView()
- Always false on remote/non-locally-controlled pawns — observers naturally see TP anim paths
- Wired in
BuildAnimSnapshots()alongside existing bools
- Use case: shared shotgun ABP branches between FP and TP fire animations on this bool
Sequencer animation authoring workflow documented
Docs/SequencerAuthoringWorkflow.md— full pipeline (create sequence from Content Browser, drag mesh assets directly, convert to Spawnable before adding constraints, bake to AnimSequence)
- Recovery from the
TransformableComponentHandleharvest crash (caused by Possessable bindings + constraints) — lesson learned the hard way mid-session whenLS_TP_Shotgun_Idle2.uassetcorrupted and crashed the editor on save
- Memory pointer:
feedback_sequencer_spawnables.md
#What's Next (in order)
#Hit Detection Refinement — complete ✅ (2026-05-20)
Per-bone collision via Physics Asset + custom ECC_WeaponTrace channel landed and verified end-to-end. Hit.BoneName populates with real bone names; bone-group anchors + per-weapon damage multipliers wired through both authoritative damage sites.
Implementation summary:
- Custom trace channel
ECC_WeaponTrace(Default Response: Block, mirrorsECC_Visibilitybehavior). Channel landed onECC_GameTraceChannel1, aliased inTypes/SLCollisionChannels.h.
- Capsule ignores
ECC_WeaponTrace(Custom collision preset onBP_SL_MasterChief). Physics Asset bodies onSK_MasterChiefblock it via the SkeletalMeshComponent's Custom collision preset.
- C++ swap: all four trace call sites moved from
ECC_Visibility→ECC_WeaponTrace(Server_ProcessShotsline trace + sphere sweep,ExecuteFire_ListenServerHostline trace,TracePredictedHitline trace).
ESLBoneGroupenum (Body/Head/Arms/Legs),FSLDamageMultipliersstruct onFSLWeaponFireMode(defaults: Body 1.0, Head 2.0, Arms 0.75, Legs 0.75),BoneGroupAnchorsmap +GetBoneGroup()parent-chain walk onASLCharacterBase.
- Debug prints gated on
bDebug(on the fire ability and weapons component independently). Prints bone name + resolved group + multiplier + final damage on every confirmed hit.
Editor configuration on BP_SL_MasterChief.BoneGroupAnchors (~5 entries — anchor walk handles descendants):
neck_01→ Head,upperarm_l/r→ Arms,thigh_l/r→ Legs
#Multi-Slot Weapon HUD — complete ✅ (2026-05-22)
Halo 3-style stacked weapon-slot strip. Both carried weapons show live ammo simultaneously; equipped weapon on top (active scale/opacity), secondary below (dimmed). Ammo updates instantly on shots and pickups. Legacy SwapAmmoWidget path fully removed.
Key implementation notes for future reference:
RebuildSlotsadds the equipped weapon first (guarantees it's at container index 0 — no reorder pass needed)
HandleEquippedWeaponChangedcallsRebuildSlots(notUpdateActiveSlot) — ensures fresh ammo values at equip time
AddAmmoon the equipped-weapon path callsSetCurrentAmmoafterSetNumericAttributeBaseto broadcast the delegate (bypassesPostGameplayEffectExecute)
- See
Docs/WeaponsSystem.md→ Ammo Display for the full architecture
#Queued next (in order)
- Melee — ✅ done.
- Weapon Clipping — ✅ done (FP). Optional leftover: AR FP ABP retract additive + TP eval (likely ignore).
- Secondary Weapon — Left-Hand Sidearm — IN PROGRESS. Foundation + draw + mesh + TP animation done & committed. Open: shooting (C++ written, uncommitted/untested → build, editor tasks, PIE, commit). Then HUD ammo indicator + death cleanup (BUG-013).
- Melee polish — minor remaining: unarmed melee anim, SFX assets, camera-shake asset assignment.
- Menus / CommonUI foundation — next after sidearm. Design + footguns fully documented in
Docs/UISystem.md. Layer scaffolding in place; missing input data, controller data assets, C++ base classes, pop API, pause wiring. SeeDocs/Progress.md#5.
#Reference for any future new weapon
Docs/NewWeaponChecklist.md — end-to-end checklist. AR is the reference pattern; duplicate and customize.
#Reference for any future new weapon
Docs/NewWeaponChecklist.md — end-to-end checklist (tags, meshes, sockets, data asset, abilities, cues, animations, HUD, pickup, test). Use as the canonical guide when adding a new weapon. AR is the reference pattern; duplicate and customize.
#Deferred shotgun polish (still outstanding from previous branch, not blocking new work)
- Shotgun fire cosmetics —
BP_GA_SL_Shotgun_PrimaryFire → OnLocallyPredictedShotFiredstill uses AR sound + AR muzzle flash Niagara. Swap when shotgun-specific assets exist.
- Shell ejection — designed but not implemented. Plan: Niagara mesh emitter + GameplayCue (
GC_SL_Shotgun_ShellEject) triggered from a pump-anim AnimNotify sendingSLTags.Events.Weapon.ShellEject. AddShellEjectSocketto the shotgun skeletal mesh.
- FP shotgun fire animation polish — barrel-snap-up + pump cycle on the FP weapon mesh.
#Test Checklist
Authoritative checklist lives in Docs/Shotgun.md → Test Checklist. Update it there as items pass. For the recent multiplayer-correctness fixes (cosmetic gating, trace channel parity, RPC batching, cancelable fire ability, cue contract), follow Docs/FireAbilityNetworkTesting.md.
#Key Docs for This Work
| Doc | When to read |
|---|---|
Docs/Shotgun.md | Full spec — data asset values, tag names, asset locations, build order, authoritative test checklist |
Docs/WeaponsSystem.md | How the weapon system works end-to-end (includes "Authoring a Fire Cue" contract) |
Docs/WeaponFireAbility.md | Fire ability internals |
Docs/FireAbilityNetworkTesting.md | PIE procedure for verifying multiplayer correctness of the fire flow |
Docs/WeaponEquip.md | Equip ability pattern |
Docs/Progress.md | Full project feature list and backlog |
#State of the Shotgun (2026-05-19)
- FP idle animation in place — looks correct, hand grip and weapon position good
- TP fire/pump animation working via state machine in
ABP_MC_TP_Shotgun(Firing state, non-looping anim, Always Reset on Entry + Inertialization)
bIsFirstPersonavailable onAnimStateSnapshotfor FP/TP branching inside shared ABPs
- Fire cosmetics still cloned from AR (
OnLocallyPredictedShotFireduses AR sound + muzzle flash) — deferred polish
- Shell ejection — designed (Niagara mesh emitter + GameplayCue + AnimNotify), not implemented — deferred polish
#Deferred Cleanup (do in a content-pass session, not mid-feature)
#Assualt → Assault content rename
14 .uasset files still carry the Assualt typo. Rename each in the Editor's Content Browser (right-click → Rename) so reference updates are automatic; do NOT rename on disk. After the renames, do a single project search-and-replace across Docs/ for "Assualt" → "Assault" to clean up doc references.
Code-referenced (most important — named as the parent BP to duplicate in Docs/Shotgun.md and Docs/WeaponFireAbility.md):
Content/SystemLink/AbilitySystem/Abilities/Weapons/AssaultRifle/BP_GA_SL_AssualtRifle_PrimaryFire
Character animations (6 — AssaultRifle/ and AssaultRifle/FP/ subfolders):
assualt-rifle-idle,assualt-rifle-idle-2,assualt-rifle-idle-breathing,assualt-rifle-run,FP/assualt-rifle-idle
Content/SystemLink/Characters/MasterChief/Anims/Library/Animations1/AssaultRifle/assualt-rifle-idle
Weapon visuals (6):
Content/SystemLink/Weapons/AssaultRifle/Mesh/M_AssualtRifleClip
…/Materials/assault-rifle-clip_M_AssualtRifleClip_BaseColor+_Normal+_OcclusionRoughnessMetallic
…/Materials/HUD/TX_AssualtRifleHud
…/UI/assualt-rifle-reticle+_Resized
#Equip-ability tag pattern — reconciled ✅ (2026-05-22)
| All three sources now use the canonical registered pattern `SLTags.Abilities.Equip.<FirstPerson | ThirdPerson>.<Weapon>`: |
|---|
SLGameplayAbility_Equip.hdoc comment updated
Docs/Shotgun.mdCDO setup section updated
Config/DefaultGameplayTags.iniis the source of truth (was already correct)
#What NOT to Touch This Session
USLGameplayAbility_FireC++ — pellet loop is done, don't re-open it
- Weapon swap HUD — deferred, not part of this branch