UI & Online · Updated 2552.07.26.14.59

Settings Menu — Build-Out Guide (Gameplay tab MVP)

How to finish the Gameplay settings screen (the milestone "stopping point": a settings screen reachable from the pause menu, gamepad-navigable, that reads + persists player setting

How to finish the Gameplay settings screen (the milestone "stopping point": a settings screen reachable from the pause menu, gamepad-navigable, that reads + persists player settings, with the command bar showing Back). The C++ backend is done and compiled; what remains is UMG authoring. Scope decided 2026-07-20: Gameplay tab only — Look Sensitivity X/Y, ADS Sensitivity, Invert Look Y/X, Field of View. Video / Audio / rebinds / tabs are deferred (see §7).

Companion docs: MenusAndOnline.md §3 (settings model + persistence split), UISystem.md §4.3 (row
classes), CommandBarBackButton.md (the command bar + its gamepad-only visibility toggle).

#1. What's already done (C++, compiled)

PieceWhereNotes
USLPlayerProfileSaveGameSettings/SLPlayerProfileSaveGame.hUSaveGame. Fields are BlueprintReadOnly (seed rows from them).
USLPlayerProfileSubsystemSettings/SLPlayerProfileSubsystem.*GameInstance subsystem. Loads/creates on init; clamped Set* mutators broadcast OnPlayerProfileChanged; SaveProfile() persists; dirty-flush on Deinitialize.
Look wiringSLPlayerController::LookApplies sensitivity + invert live; blends ADS multiplier by zoom alpha. Defaults are identity → no feel change until edited.
FOV applySLPlayerCharacter::SetBaseFieldOfView + BeginPlayController pushes FOV on OnPlayerProfileChanged; character pulls saved FOV at spawn.

Save slot: "SLPlayerProfile", user index 0. Subsystem access in BP: Get Game Instance Subsystem → class SL Player Profile Subsystem.

Assets created (EMPTY — parents set, no tree/graph):

  • WBP_SL_SettingsRow_Slider — parent USLSliderRow
  • WBP_SL_SettingsRow_Toggle — parent USLToggleRow
  • WBP_SL_Settings — parent USLScreenWidget
Why empty: fresh UE 5.7 WBPs are rootless and the editor-script bridge cannot mint/swap a root, so trees
are hand-authored in UMG. The bridge can set row instance properties + verify in PIE (see §6).

#2. The value contract (how the rows talk to C++)

Each row is a thin BP over a C++ base that owns the value. The BP only wires the control and reflects the value; the owning screen seeds the value and listens for changes.

  • Slider (USLSliderRow): control → SetValueFromNormalized(0..1); reflect in `OnValueRefreshed(Value,
  • Normalized). Programmatic Slider·SetValue does not re-fire OnValueChanged` → no feedback loop.

  • Toggle (USLToggleRow): control → SetValue(bool) (or ToggleValue()); reflect in
  • OnValueRefreshed(bInValue).

  • Both expose OnValueChanged (BlueprintAssignable) — the screen binds this to the subsystem's Set*.
  • Label comes free: set each instance's DisplayName (the base fills the LabelText block).

#3. Step 1 — the two row widgets

#WBP_SL_SettingsRow_Slider

Tree (root HorizontalBox):

  • LabelTextCommonTextBlock, named exactly LabelText (base auto-fills it). Fill horizontally.
  • SliderUSlider.
  • ValueTextCommonTextBlock (numeric readout).

Graph:

  • Slider · On Value Changed (float)Set Value From Normalized (New Value).
  • Event On Value Refreshed (Value, Normalized)Slider · Set Value (Normalized) and
  • ValueText · Set Text (Value → Text).

#WBP_SL_SettingsRow_Toggle

Tree (root HorizontalBox):

  • LabelTextCommonTextBlock, named exactly LabelText.
  • ToggleCheckCheckBox (simplest reliable control; swap to a styled ON/OFF USLButtonBase later).

Graph:

  • ToggleCheck · On Check State Changed (bool)Set Value (In Value).
  • Event On Value Refreshed (bInValue)ToggleCheck · Set Is Checked (bInValue).

Compile + Save both. Then ping — the bridge configures row instances in §4 for you.


#4. Step 2 — WBP_SL_Settings (the screen)

Tree (root Overlay, fill):

  • (optional) background panel/image.
  • Title CommonTextBlock "SETTINGS" (top).
  • RowsBoxVerticalBox, centered, holding six row instances (names matter for §6/graph):
Instance nameWidgetDisplayNameRange (Min / Max / Step)Profile fieldSetter
Row_SensXSlider rowLook Sensitivity X0.1 / 5.0 / 0.05LookSensitivityXSetLookSensitivityX
Row_SensYSlider rowLook Sensitivity Y0.1 / 5.0 / 0.05LookSensitivityYSetLookSensitivityY
Row_ADSSlider rowADS Sensitivity0.1 / 2.0 / 0.05ADSSensitivityMulSetADSSensitivityMul
Row_InvertYToggle rowInvert Look YbInvertLookYSetInvertLookY
Row_InvertXToggle rowInvert Look XbInvertLookXSetInvertLookX
Row_FOVSlider rowField of View70 / 120 / 1FieldOfViewSetFieldOfView
  • CommandBar — drop WBP_SL_CommandBar at the bottom (Back prompt; hides itself on KBM — see
  • CommandBarBackButton.md).

Ranges stay inside the subsystem's clamps (Sens [0.05,10], ADS [0.1,2], FOV [70,120]). The bridge will
set each instance's DisplayName + Min/Max/Step for you once the tree exists — you don't hand-type them.

Graph:

  1. Get Desired Focus Target (REQUIRED) — override it to return Row_SensX (or its Slider). USLScreenWidget
  2. fails to compile without this (footgun 6.29-adjacent).

  1. Event Construct → seed + bind. Get the subsystem once (`Get Game Instance Subsystem → SL Player Profile
  2. Subsystem), promote to a var ProfileSub; ProfileSub → Get Profile` for the read.

    • Seed (use bBroadcast = false so seeding doesn't write back):
    • Row_SensX · SetValue(Profile.LookSensitivityX, false), …, Row_InvertY · SetValue(Profile.bInvertLookY, false), etc. (Ranges are already set as instance defaults per §6, so no SetRange needed here.)

    • Bind each row's OnValueChanged → the matching ProfileSub setter (table above). Slider rows pass a
    • float; toggle rows pass a bool.

  1. Persist on close. On the activatable's On Deactivated (BP_OnDeactivated) → `ProfileSub · Save
  2. Profile`. (Live edits already apply + broadcast; this writes to disk when you back out.)

Do not wire Back/close behavior — USLScreenWidget is already a back-handler (B/Esc pops it), which is
what fires On Deactivated.

#5. Step 3 — reach it from the pause menu

On WBP_SL_PauseMenu, the Settings button (currently a stub):

  • On ClickedGet Owning PlayerUSLPrimaryGameLayout · Get For Player
  • Push Widget To Layer (Layer = Menu, Widget Class = WBP_SL_Settings).

This pushes Settings on top of the pause menu on the Menu stack; pressing B/Esc pops it back to the pause menu automatically.


#6. What the bridge does (so you don't)

Once §3 + the §4 tree exist (correct instance names), the editor-script bridge will:

  • Set each row instance's DisplayName + slider MinValue/MaxValue/StepSize per the §4 table.
  • Read back the compiled tree to confirm binds resolved.
  • Drive PIE checks for the §8 gate.

Ask for it after each step — this is the "verify one CommonUI change at a time" discipline.


#7. Deferred (not in this slice)

  • Dropdown/rotator rowUSLDropdownRow exists in C++; no WBP_SL_SettingsRow_Dropdown yet. First use =
  • ADS/Crouch Hold vs Toggle. Author on a UCommonRotator (gamepad left/right cycling).

  • Video tab (UGameUserSettings), Audio tab (Sound Classes), Controls/rebinds
  • (UEnhancedInputUserSettings) — Phase C4/C5 in MenusAndOnline.md §6.

  • Real tabs (USLTabListWidget + switcher) — only needed once there's more than one tab.

#8. Definition of done (PIE gate)

  1. Pause → Settings opens the screen; command bar shows Back (gamepad); B/Esc returns to pause.
  1. Gamepad navigates all six rows; sliders adjust, checkboxes toggle.
  1. Invert Look Y ON → look pitch inverts immediately (the headline ask). Sensitivity + FOV apply live.
  1. Change values → back out → reopen: values persisted (written on On Deactivated, reloaded next boot).
  1. On KBM the command bar hides (by design); the screen still works with mouse.

#9. Session findings — 2026-07-21

Focusability (the core CommonUI constraint): only a UCommonButtonBase takes gamepad focus. Two answers in play:

  • Rows (USLSettingsRowWidget) are focusable directly (SetIsFocusable(true)) and handle Left/Right/Accept
  • in NativeOnKeyDown; Up/Down fall through to Slate nav; highlight via OnRowFocusChanged. ⚠ The inner USlider/CheckBox must have Is Focusable = OFF, or focus lands on them and the row's keys never fire.

  • Discrete choices → UCommonRotator (it already is a button, so focus is free). DECISION: use a
  • rotator for Invert Look Y/X (Off/On) and future discrete rows; keep sliders for continuous Sens/FOV. Port the 5.6 USystemLinkSettingsRotator (C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\): arrow buttons for mouse, NativeOnClicked(){} to kill click-cycle. Next code: USLRotatorRow : UCommonRotator + bool↔index map

    • OnValueChanged the screen binds to the setter — this is also the deferred USLDropdownRow slot (§7).

Verified: both screens are InputModeOnActivate=MENU + pause; controller B backs out; CommonInput supports mouse. Slider Row_SensX adjusts on arrow keys and gamepad.

KBM cursor — FIXED + REBUILT + WORKING. Cursor vanished in-menu because CommonUI hides the OS cursor while the active device is a gamepad, and it never flipped back on mouse use. USLCommonActivatableWidget now shows the cursor for MouseAndKeyboard / hides for Gamepad (via HandleInputMethodChanged, seeded on activate) with a NativeOnMouseMove reveal fallback; RestoreGameInputMode still hides it on final close. PIE-confirmed working.

Testing note: Esc stops PIE (the editor reserves it) — test Back with gamepad B, or add Backspace to IA_SL_UI_Back for KBM.


#10. Rotator row — build state + finish recipe (▶ resume point)

USLRotatorRow : UCommonRotator — WRITTEN + REBUILT (SLRotatorRow.h/.cpp, registered). API: SetOptions, SetSelectedIndex(idx, bBroadcast), SetBoolValue(b, bBroadcast) / GetBoolValue, delegates OnValueChanged (int32) + OnBoolChanged(bool), SetDisplayName. Gamepad Left/Right cycles natively; A/click advances; arrow buttons drive it for mouse. Seeding via SetSelectedIndex(..., false) does NOT write back (base SetSelectedItem doesn't fire the rotate event).

Engine contract (verified in UE 5.7 source): UCommonRotator needs a BindWidget UCommonTextBlock named exactly MyText (the value display) — required, not optional.

WBP_SL_SettingsRow_Rotator — CREATED via bridge, parented to USLRotatorRow (via WidgetBlueprintFactory(parent_class=...), so no reparent/crash), saved, but ROOTLESS. The bridge cannot set WidgetTree.RootWidget in 5.7 (no Python setter). A harmless orphan RowBox HorizontalBox is in the tree from the probe.

Finish steps:

  1. (Beepers, manual — the only bridge-blocked step) open WBP_SL_SettingsRow_Rotator → drag a **Horizontal
  2. Box as the root** (name RowBox) → Compile + Save.

  1. (Bridge) under the root add: LabelText [CommonTextBlock, fills left] · LeftArrowButton [Button, **Is
  2. Focusable OFF] · MyText [CommonTextBlock, exact required name] · RightArrowButton [Button, Is Focusable OFF**]. Target: [ Invert Look Y .......... ◄ Off ► ]. Compile + save.

  1. (Bridge/UMG) in WBP_SL_Settings swap Row_InvertY + Row_InvertX from the toggle WBP → the rotator
  2. WBP. Per instance: SetOptions({"Off","On"}) + SetDisplayName. Graph: on Construct SetBoolValue(Profile. bInvertLookY, false) to seed; bind OnBoolChangedProfileSub·SetInvertLookY (and X).

  1. PIE gate: Invert Look Y flips on gamepad Left/Right and A; mouse arrows work; look pitch inverts live;
  2. value persists across back-out/reopen.