How-To: Brightness & Safe Zone Calibration

Goal: give the player a full-screen gamma calibration screen and a title-safe frame screen, both adjusted live so they judge the real picture instead of a preview.

Prerequisites
The Video collection (How-To: Use the Ready-Made Collections), which already contributes both action rows. You author the two screens; the rows exist either way.

1. What already exists

Screen classRow classWhat it editsNamed action tag
UCrimsonBrightnessEditorUCrimsonSettingAction_BrightnessEditorUCrimsonSettingsLocal::SetDisplayGamma, clamped 1.0 - 3.0CrimsonSettings.Action.Brightness
UCrimsonSafeZoneEditorUCrimsonSettingAction_SafeZoneEditorUCrimsonSettingsLocal::SetSafeZone, which feeds SSafeZone::SetGlobalSafeZoneScaleCrimsonSettings.Action.SafeZone

Both screen classes are Abstract UCommonActivatableWidgets that also implement ICrimsonSettingActionInterface - the plugin ships behavior, you author the Widget Blueprint. Each action row supplies a child scalar setting rather than being a plain button, which is what makes Done write through the registry so the change tracker sees it and Apply lights up. Writing UCrimsonSettingsLocal directly would land the value and leave the screen looking untouched.

The gamma range is wider than Lyra's on purpose
1.0 (linear, everything crushed) to 3.0 (flat, everything lifted). Lyra clamps its slider to 1.7 - 2.9; a calibration pass is the one place a player is entitled to run the panel to either end while they line the mark up.

2. Author the screen widgets

Create a Widget Blueprint parented to each class and give it these binds. Both classes want the same four names.

Widget nameTypePurpose
Switcher_SafeZoneMessageUWidgetSwitcherHolds the instruction line. The class switches it and rewrites the text per input device.
RichText_DefaultUCommonRichTextBlockThe instruction line. Authored with placeholder body text; the class writes the live text.
Button_BackUCommonButtonBaseRestores the value the screen opened on and closes.
Button_DoneUCommonButtonBaseCommits through the row's child setting and closes.

Then add the instrument each screen needs between its heading and the instruction line: for brightness, a step wedge with a low-value mark overlaid on the black end (the player raises gamma until the mark stops separating from its background); for safe zone, a frame outline wrapped in a SafeZone whose slot has bIsTitleSafe set and SafeAreaScale 1 on all four sides.

Reference implementation
(CrimsonCore - requires all Crimson plugins) W_BrightnessEditor and W_SafeZoneEditor (/CrimsonCore/UserInterface/Settings/Screens/) are complete worked examples. Study the instrument in each: the brightness one is 24 flat Slate steps stepped in sRGB with a 56 px mark on the black end; the safe-zone one is a SafeZone-wrapped 2 px outline sitting in the ROOT overlay rather than the content column, because it marks the screen's edges. Duplicate either as a starting point.
Verify
Both Blueprints compile with no missing-BindWidget errors.

3. Push them when a row fires

An action row broadcasts the registry's named-action event with the setting and its action tag. Subscribe on your screen, match the tag, push the matching widget onto your Menu layer, and call ExecuteActionForSetting(ActionTag, Setting) on it so it knows which child setting to write through.

Blueprint or C++
The registry broadcasts the same thing twice. C++ binds OnSettingNamedActionEvent, a native DECLARE_EVENT_TwoParams. Blueprint binds On Setting Named Action (OnSettingNamedActionBP), a BlueprintAssignable mirror fired with the same Setting and Action immediately after the native one - assign it inside your Create Registry event, on the registry you just built, before returning it. Either route works, so this page is Blueprint work end to end.
cpp
// MySettingsScreen.h
#include "Widgets/CrimsonSettingScreen.h"
#include "MySettingsScreen.generated.h"
UCLASS(Abstract)
class UMySettingsScreen : public UCrimsonSettingScreen
{
GENERATED_BODY()
protected:
virtual void NativeOnInitialized() override;
UPROPERTY(EditDefaultsOnly, Category = "Editors")
TSoftClassPtr<UCommonActivatableWidget> BrightnessEditorClass;
UPROPERTY(EditDefaultsOnly, Category = "Editors")
TSoftClassPtr<UCommonActivatableWidget> SafeZoneEditorClass;
private:
void HandleNamedAction(UCrimsonSetting* Setting, FGameplayTag ActionTag);
};
// MySettingsScreen.cpp
#include "MySettingsScreen.h"
#include "CrimsonSettingRegistry.h"
#include "CrimsonSettingsTags.h"
void UMySettingsScreen::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (UCrimsonSettingRegistry* Reg = GetOrCreateRegistry())
{
Reg->OnSettingNamedActionEvent.AddUObject(this, &ThisClass::HandleNamedAction);
}
}
void UMySettingsScreen::HandleNamedAction(UCrimsonSetting* Setting, FGameplayTag ActionTag)
{
if (ActionTag == CrimsonSettingsTags::Action_Brightness)
{
// Push BrightnessEditorClass onto your Menu layer, then call
// ICrimsonSettingActionInterface::Execute_ExecuteActionForSetting(Widget, ActionTag, Setting).
}
else if (ActionTag == CrimsonSettingsTags::Action_SafeZone)
{
// Same, with SafeZoneEditorClass.
}
else if (ActionTag == CrimsonSettingsTags::Action_Benchmark)
{
RunHardwareBenchmark();
}
}
How both screens behave once open
The mouse wheel and the gamepad right stick move the value live. The right stick and not the left: the left stick is CommonUI's analog navigation, and a screen that consumes it is a screen whose Back / Done buttons a pad can never reach. Both are read straight from Slate (NativeOnAnalogValueChanged on Gamepad_RightY, past a 0.2 dead zone, and NativeOnMouseWheel) - no Input Action asset is involved.

Both screens name a desired focus target (Back, or Done when bCanCancel is off) and ask for a Menu FUIInputConfig, so a pad lands on a button the moment the screen opens. Back restores the value the screen opened on and is the CommonUI back action, so B / Circle closes it the way it closes every other screen. The instruction line names the right control for the device in hand, re-resolved on every input-method change.
Verify
Click Brightness in the Video tab. The screen opens, the right stick and the wheel both move the picture, Back restores what you started with, and Done leaves Apply lit on the settings screen.

4. Safe zone on desktop

The Safe Zone row is hidden by default on PC
SSafeZone scales the platform's title-safe margin, and on Windows that margin is zero unless a debug override is running. Scaling zero gives zero, so the frame would not visibly move - engine behavior, not a bug in the screen.

FCrimsonSettingEditCondition_SafeZoneEditable therefore kills the row unless one of two things is true: the platform declares the Platform.Trait.SupportsSafeZoneEditing trait (add it to PlatformTraits under [/Script/CommonUI.CommonUISettings] in your Config/<Platform>/<Platform>Game.ini - the shipping path for consoles), or the display actually reports title-safe padding right now.

To bring the row up on PC for calibration work, open the console in a PIE or standalone session and run:

text
r.DebugSafeZone.TitleRatio 0.9

That is the whole recipe. The CVar makes FDisplayMetrics::ApplyDefaultSafeZones fill TitleSafePaddingSize with 5% of the display on each edge and broadcasts FCoreDelegates::OnSafeFrameChangedEvent, which every live SSafeZone listens to - so the HUD insets immediately. Reopen the settings screen, or navigate away and back so the registry regenerates, and the Safe Zone row is there. r.DebugSafeZone.TitleRatio 1 puts it all back and the row disappears again.

The 'immediately' is this module's doing
SSafeZone::UpdateSafeMargin reads FSlateApplicationBase::GetCachedDisplayMetrics, and nothing rebuilds that cache when the CVar changes - Slate seeds it at startup and refreshes it only when the OS reports a display change. Left alone, the ratio changes, every safe zone dutifully recomputes, and every one of them reads the same stale zero.

FCrimsonSettingsModule subscribes to OnSafeFrameChangedEvent and rebuilds the metrics when the ratio has actually changed. Multicast delegates run their subscribers in reverse registration order, and every SSafeZone subscribes when it is constructed - long after the module started - so by the time the module's handler runs the safe zones have already recomputed from the stale cache. The module therefore broadcasts the event once more (re-entrancy guarded) after re-seeding, and that second pass is the one that moves the HUD.
Your HUD layout has to be wrapped in a SafeZone
The scale this screen edits is a global multiplier applied inside each SSafeZone; it cannot inset a widget that is not in one. Your HUD layout must have a SafeZone as its root, with its slot's bIsTitleSafe set and SafeAreaScale 1 on all four sides. Menu-layer layouts are deliberately not wrapped - menus are drawn edge to edge and each screen insets its own content. Without the HUD wrap, the calibration screen's own frame moves and nothing behind it does, which reads as the setting being broken.

The per-edge alternative, if you want asymmetric padding, is SafeZone.EnableOverrides 1 plus the four SafeZone.Ratio.* values. Unlike r.DebugSafeZone.TitleRatio these do not broadcast OnSafeFrameChangedEvent on change - the metrics rebuild inside UCrimsonSettingsLocal::IsTitleSafePaddingAvailable() is what picks them up, so open the settings screen after setting them.

Verify
With r.DebugSafeZone.TitleRatio 0.9 set, the Safe Zone row appears after a regenerate, and moving it visibly pulls a SafeZone-rooted HUD in from the screen edges.

See also

  • How-To: Use the Ready-Made Collections - where both rows come from.
  • How-To: First-Run Setup Wizard - the usual first place a player is asked to calibrate gamma.
  • API Reference -> Presentation - UCrimsonBrightnessEditor and UCrimsonSafeZoneEditor.