Semantic retained controls and input scopes

September 18, 2026 · View on GitHub

8 September 2026. The retained runtime now owns button focus, navigation, press/release pairing, modal input scopes and authored state feedback. This advances the replacement runtime and supplies a shared interaction model for the future native editor. The subsequent input integration connects this model to SDL and session ownership. The game action bridge, remaining widgets and GUI translation are still incomplete.

Authored control contract

Any canonical node can declare a control object:

{
  "role": "button",
  "action": "menu.controls",
  "label": "#str_200083",
  "enabled": true,
  "states": {
    "default": "controls.default",
    "hover": "controls.hover",
    "focus": "controls.focus",
    "pressed": "controls.pressed",
    "disabled": "controls.disabled"
  },
  "navigation": {"down": "system"}
}

role, action, localized label and all five state timelines are required. enabled defaults true; navigation and extensions are optional. Actions and navigation targets are validated stable IDs, not executable command text. Labels use existing #str_ keys and are stored for semantic consumers; platform accessibility exposure is not implemented yet. Button controls cannot nest inside other buttons. Other roles are rejected until their behaviors are implemented.

The existing JSONC source/diagnostic contract applies to every field. Timeline references must resolve, run once, target only this control's subtree and cover the same effective properties in all five states. This prevents a pressed offset or highlight from remaining stuck because its release state omitted a property. Feedback can change the button's own paint/opacity; layout and transform changes must target child parts, preserving the button's hit box. A button establishes position:relative by default, so absolute marker/rail parts anchor to its local box. An explicit authored position takes precedence.

Feedback chooses disabled, pressed, focus, hover, then default in that order. State changes play the authored timeline through the existing monotonic motion evaluator. Interruption retargets from the current sample; reduced motion follows the same evaluator policy as other UI motion. Feedback is initialized at document load, before subsequent explicit application timelines acquire ownership. The fixture uses separate editable orange inset rails, label color and a 1 dp pressed label offset. Its right-hand panel is a test input scope, not an accepted production modal design.

Interaction ownership

Interaction is independent of RmlUi, engine headers and devices. The layout adapter supplies presented control bounds and hit IDs. It owns source-order tab/reverse-tab navigation, spatial arrow navigation, explicit navigation links, enabled state, focus, hover, armed activation, nested modal scopes and action requests. Explicit links apply when their targets are eligible; otherwise normal navigation skips disabled/hidden/out-of-scope controls. Directional navigation selects the nearest forward candidate using squared forward distance plus four times squared lateral distance; ties preserve source order. Tab wraps within the active scope. With no focus, forward navigation chooses the first eligible control and reverse tab chooses the last.

The runtime API accepts window-coordinate pointer movement, primary-pointer down/up and semantic MenuInput requests. Window pixel density and viewport origin convert once into document pixels. The stored window position is reprojected after viewport changes without requiring a new device event. RmlUi resolves transforms during rendering, so hit/navigation bounds are refreshed after the presented frame. Child artwork cannot enlarge a button's hit box; the hit is checked against its own projected border box as well as the layout library's hit result. The 17 September update also constrains pointer targeting to positive geometric alpha from the control's own and ancestor masks. Empty or fully transparent masks remove their subtrees from focus; full control/mask intersection for directional focus remains unfinished.

An activation requires a matching release on the armed, still-eligible control. Repeated accept-down/pointer-down events cannot duplicate activation. Dragging off the button, navigating to another control, disabling it, losing valid output, cancelling input, changing modal scope or replacing the document cancels the arm. Held-input state survives cancellation/replacement until release, preventing a repeat or release from activating a replacement target. The pointer and accept paths share a single arm, so simultaneous input streams cannot both own one activation. Device adapters must aggregate their physical inputs into these semantic transitions; this API does not implement a platform repeat scheduler.

PushModal(root) constrains input to that existing subtree immediately, chooses its first eligible control and remembers prior focus. Further scopes must be descendants. PopModal() cancels the arm and restores eligible prior focus, or the first eligible control in the enclosing scope. Scopes own input independently of visual transition duration. Opening/closing a visual modal and scroll-to-focus remain application/widget responsibilities.

TakeActions() returns owned document/node/action strings. Activation requests and Back requests have distinct kinds. No action string is executed by this runtime. Requests are cleared on document replacement, bounded to 256 between drains, and overflow is diagnosed. Enabled state is an instance override; it does not rewrite authored source. Unknown control-state queries return an empty optional.

The independent text-scale increment also keeps previously visible focus in view after its layout or an ancestor viewport changes. Explicit focus records its layout immediately, so an edit before the next frame receives the same treatment. Relative layout geometry is tracked separately from scroll offsets. Deliberate scrolling can hide focus, and a later layout change preserves that choice until focus is explicitly revealed again. Density/snapshot restoration retains its authored-scroll preservation policy.

Engine qualification without device control

The preview exposes semantic development commands: ui_retainedFocus, ui_retainedMenu, ui_retainedEnabled, ui_retainedModal, ui_retainedState and ui_retainedEvents. They exercise the runtime API and print focus/actions; they never read, inject or move OS input. Quote stable IDs in engine command text, including IDs containing hyphens. These commands alone do not qualify platform input. The later input integration adds a distinct ui_retainedOpen host and tracks its device-routing/ownership evidence.

The capture harness accepts --retained-script for a bounded sequence of these commands and waits. It rejects other commands, unquoted IDs and excessive script size/waits. Scripts are hashed in capture metadata and replayed after an optional video restart. Command-usage failures are retained diagnostics, not successful interaction. The qualification script checks deduplicated activation, disabled navigation, modal containment/restoration, release cancellation and live enable/disable. It leaves a pressed control visible. The verifier checks exact action/state traces, the rendered disabled plate's alpha and the pressed orange rail in the engine TGA. Screenshots require separate visual inspection.

Native tests use the same canonical fixture and real layout/render adapter. They also cover pointer hit through a label, density/origin conversion, drag-off, queued/held input across replacement, cancellation, stationary-pointer viewport changes, rotated ancestors, invalid output, visible vector focus feedback, source round trips, localized labels and rejected malformed state/action data. Document, retained runtime and vector suites pass.

The staged Windows package passes the semantic/pixel verifier on both backends:

CaptureOutput / densityScript repetitionsActivations / state observationsDisabled plate errorOrange rail pixels
SP game/airdefense1, OpenGL, video restart1280×720 / 125%22 / 100.55 of 255 across 186 pixels1,835
MP mp/q4dm1, Vulkan1920×1080 / 200%11 / 50.55 of 255 across 600 pixels3,070

Each repetition produces exactly [0,1,0] action-batch counts: the initial release does nothing, the repeated accept pair activates once, and modal teardown cancels the held release. Both engine TGAs were inspected: the disabled plate and label fade together, the pressed inset rail and label are visible, and the editable panel/button chamfers remain intact. The capture is a test layout; its static orange Controls plate is inherited fixture art, not selection-state implementation or a completed production screen.

Reproduce using the staged package and installed retail assets:

python tools/ui/capture_legacy_baseline.py --assets 'C:\Program Files (x86)\Steam\steamapps\common\Quake 4' --mode sp --renderer gl --retained-document tools/ui/fixtures/interaction-smoke.q4ui --retained-script tools/ui/fixtures/interaction-smoke.cfg --density 1.25 --video-restart --profile-frames 60 --output .tmp/ui/interaction/sp-gl-125-qualified
python tools/ui/capture_legacy_baseline.py --assets 'C:\Program Files (x86)\Steam\steamapps\common\Quake 4' --mode mp --renderer vulkan --retained-document tools/ui/fixtures/interaction-smoke.q4ui --retained-script tools/ui/fixtures/interaction-smoke.cfg --density 2 --width 1920 --height 1080 --profile-frames 60 --output .tmp/ui/interaction/mp-vulkan-200-qualified
python tools/ui/verify_interaction_capture.py .tmp/ui/interaction/sp-gl-125-qualified
python tools/ui/verify_interaction_capture.py .tmp/ui/interaction/mp-vulkan-200-qualified

Use fresh output directories for reruns. The retained sources and semantic script are copied into isolated savepaths; no OS device input is supplied. Both runs enter their gameplay maps and use the registered engine screenshot command. The OpenGL run repeats the script after a windowed renderer restart.

The final client SHA-256 is 56bf531e1484a21e938076f80f20dc1677130a02fddf8c5ba875e905940344d3. The unchanged companion source revision is cc83b0c972c0bef9474462461818096dd690277e; no shared interface changes are required by these controls. The fixture SHA-256 is 77884c8ec65cd2546062130444f5548eebe23026ebc5e970648ee0bb17dd830d and the semantic script is 058330d64abf533f6919dc43fce59dbdf5cae161dc0ef61a647c20a2191025d2. Engine screenshot hashes are fd953b5b29811af1f1b46578a5faf0ef5c7453980531e6137df38441a6d1f99b (SP/OpenGL) and 15439fc3c3dc21362b6efd0e3d002b2d9665d3954ff795897148ce6970d50a28 (MP/Vulkan). Full binary/source/log hashes, traces and verifier results are retained in .tmp/ui/interaction/summary.json and each capture's capture.json.

The 60-frame MSVC Debug CPU samples measure 2.417/3.337 ms p50/p95 on OpenGL, 2.461/3.126 ms after restart, and 3.774/6.079 ms on Vulkan. Initial compilation spikes reach 32.343 ms and 39.877 ms respectively. These are development CPU measurements, not GPU, optimized-build or full-corpus performance acceptance. Shared GUI ownership was disabled in these captures. There are no retained UI diagnostics; SP has no warnings/errors. MP has the same 93 stock warnings (86 unique messages, identical multiplicities) as the preceding mask checkpoint, with zero errors. These existing MP issues remain open.

Remaining integration

Broader device/platform qualification, touch/scroll/text editing and IME, game-state bindings/action dispatch, selection, busy/error semantics, sounds, other widget roles, native editor tools, complete GUI translation and broad platform/visual qualification remain open. The current preview remains a developer renderer; the subsequent ui_retainedOpen host owns input but still needs application/game dispatch and complete widgets.

Transformed overflow clipping still reaches RmlUi's separate clip-mask API, which the retained renderer has not implemented. The canonical alpha-mask feature does not cover that interface. Consequently transformed/scrolling clipping and matching input boundaries remain an explicit qualification gap; the rotated-pointer native test covers hit projection without overflowing content. This checkpoint accepts no stock GUI resource as a finished replacement.