Skip to content

Halcyon XR Overlay

Halcyon XR Overlay (a working name) is a standalone VR overlay app, the first thing in the Apps/ group. It draws Halcyon interfaces over whatever VR game is running, the way XSOverlay and OVR Toolkit do. It is not part of the engine: it composes the Halcyon.* libraries and nothing under Engine/, Toolchain/ or Content/, carries no DigitalHeaven branding, and never loads a pallet.

The app never starts SteamVR. It tries a background connection first, which SteamVR refuses when no server is up, and keeps trying every startup.connectRetrySeconds for up to startup.connectWaitSeconds (two minutes), so launching it a moment before SteamVR, or having SteamVR launch it, works. Then it exits with a log line saying so. A runtime that is not installed at all exits at once. Only one copy runs: a second launch writes second-launch.log beside the settings, says so on the console and exits with code 4, leaving the running copy’s log alone.

Terminal window
dotnet run --project Apps/HalcyonXrOverlay

--interactive starts the hand widget in interactive mode instead of compact.

The screens are live: each display switched on in the monitor bar shows its desktop through display capture. The log says which GPU capture runs on and every change in a capture’s status. Windows only for now; elsewhere the screens keep their placeholders, with the status drawn on them.

With SteamVR up, you should see:

  • The hand widget on the left controller (or the right, from the settings), in compact mode: the time on one card, read-only. Interactive mode slides a button card with Desktop, Keyboard and Settings out from under it. Pressing a button writes a line such as Hand widget button pressed: Keyboard to the console and the log, and each button switches on and off. Desktop opens the monitor bar above the clock, Keyboard opens the keyboard panel in front of you, and Settings opens the settings panel in front of you (or, set to the dashboard, the dashboard on the settings pane), on exactly while it is open.
  • A dashboard tab named Halcyon XR Overlay with an XR thumbnail on SteamVR’s dashboard bar. It opens the settings pane.
  • A tray icon. A plain left click opens the settings in a desktop window. The right-click menu has Settings first (the same window), then a Settings in the SteamVR dashboard checkbox, an Interactive mode checkbox, a Start with SteamVR checkbox and Quit.

Quitting from the tray, or SteamVR shutting down, releases the overlays, the Vulkan device and the SteamVR connection in that order. SteamVR’s quit is acknowledged (AcknowledgeQuit_Exiting) before the app exits.

Not in compact mode, which answers nothing at all. In interactive mode the other hand reaches it two ways: its fingertip pokes it, and its laser points at it from across the room (see below). SteamVR’s own laser mouse never reaches the card, dashboard or not; it stays on the settings pane, where it works. The card stays visible while the dashboard is open, but it answers nothing then: SteamVR owns the controllers (see below).

SteamVR keeps a list of overlay apps it launches at its own start (Settings, Startup / Shutdown, Manage Startup Overlay Apps). The app puts itself in that list, so it can replace another overlay there.

  1. Build, then run the app once while SteamVR is up. On every start it writes halcyonxroverlay.vrmanifest and halcyonxroverlay-icon.png into the folder the executable runs from (the config directory if that folder is read-only) and registers the manifest with SteamVR.
  2. In SteamVR open Settings, Startup / Shutdown, Manage Startup Overlay Apps. Switch the old overlay off and Halcyon XR Overlay on. The tray’s Start with SteamVR checkbox does the same, and shows SteamVR’s setting: a change made in either place appears in the other within a second, and in startup.launchWithSteamVR.
  3. From then on SteamVR starts the build the manifest names.

Registering never turns the switch on. The manifest is source: builtin, app key halcyon.xroverlay, launch_type: binary, is_dashboard_overlay: true, with the executable’s absolute path under the running platform’s key (binary_path_windows, binary_path_linux or binary_path_osx) and its folder as the working directory. The shape follows the manifests other SteamVR overlays ship and the application properties in Valve’s openvr.h; the registration is IVRApplications::AddApplicationManifest (permanent), IsApplicationInstalled, GetApplicationAutoLaunch and SetApplicationAutoLaunch.

Running a build from a different folder writes a manifest there and registers it under the same app key, which is meant to move SteamVR’s entry to that build; this has not been confirmed against a live SteamVR yet, so check the list after moving a build and, if the old path is still shown, run the old build once or remove it from SteamVR’s list. Run once from the build you want SteamVR to launch.

Terminal window
dotnet run --project Apps/HalcyonXrOverlay -- --capture <dir>

This renders every surface on a headless Vulkan device and writes to <dir>:

  • wrist-compact.png: compact mode, the clock alone.
  • wrist-opening.png: halfway through the button card sliding out.
  • wrist-interactive.png: interactive mode, fully open.
  • wrist-poke-hover.png and wrist-poke-pressed.png: the other hand’s fingertip hovering the middle button, then pushed through it.
  • wrist-laser-hover.png and wrist-laser-pressed.png: the other hand’s laser, 40 cm away, hovering the first button and then pressing it with the trigger.
  • wrist-laser-composed.png: the hover frame with the laser’s dot drawn where the compositor puts it, seen square on.
  • laser-beam.png and laser-dot.png: the beam’s and the dot’s images (with laser-dot-armed.png, armed for a right click, and laser-dot-grab-ready.png, its grab-ready ring in the accent), before their surfaces stretch and place them. The log line Laser: the beam is … says how the beam was placed in that frame: its length, its width and how far its face turned toward the head.
  • wrist-grabbable.png: unit mode, the other controller in grab range, both cards’ rims and fills lifted. The scale-up is the surface’s size rather than the image’s, so the log says how wide the surface was.
  • wrist-bar-compact.png, wrist-bar-rest.png, wrist-bar-hot.png and wrist-bar-held.png: a second unit in bar mode, with the bar at rest under the clock and under the button card, then grown and opaque under the other controller with the unit lifted, then held by the grip.
  • wrist-buttons-off.png, then for each of Desktop, Keyboard and Settings wrist-<button>-on.png, -on-hover.png and -on-pressed.png: every button off, then each one switched on, under the pointer while on, and pressed while on. Settings is on because its pane is showing on the offline dashboard.
  • wrist-monitors-connected.png and wrist-monitors-disconnected.png: the monitor bar out over three fake displays (the middle one primary, the right one with a long name), the first switched on, tucked behind the clock card and as a separate bar. wrist-monitors-none.png is the bar with no displays, wrist-monitors-seven.png seven displays wrapped into three rows, and wrist-monitors-opening.png the bar halfway up. wrist-monitors-one.png and wrist-monitors-two.png show one and two displays. With any display listed, the end of the bar’s last row is the narrow Recenter icon cell.
  • screens-desk-curved-1.png to -3.png and screens-desk-flat-1.png to -3.png: the screens with one, two and three placeholder screens on in mltn’s arrangement (a 2560×1440 27HC5R on the left, the 3440×1440 DELL S3422DWG primary in the middle, a 3440×1440 LG ultrawide on the right), switched on primary first; screens-offset-curved-3.png and screens-offset-flat-3.png the same three with the left monitor 360 pixels lower and the right one 200 higher; screens-desk-middle-off-*-2.png, screens-desk-left-off-*-2.png and screens-offset-middle-off-*-2.png the wall after the middle or left display is turned off, and screens-desk-sliding-*-2.png the same part way through the slide. Each is the wall unrolled along its cylinder, as a person at the axis sees it, with a grab bar at rest under every screen when the panel grab is the grab bar alone. Every one has a -top.png beside it: the wall from above with the head at the center, each screen’s arc where the compositor puts it, its back’s flat plate as a thin line behind it, the cylinder at screens.radiusMeters and the bar in white. The log gives each screen’s position, yaw, size and OpenVR curvature, and the bar’s position. screen-1.png to screen-3.png are the placeholder images each screen’s surface is handed.
  • keyboard-<layout>-flush.png and keyboard-<layout>-inked.png: the keyboard panel on every built-in layout in both key styles, its text field holding a line typed in echo output.
  • keyboard-transition.png: a switch from the Preonic to the ALT caught at t = 0.2, the frame past its new width in the elastic overshoot, the old keys almost gone and the new ones half in. The log line Keyboard: the switch at t … gives the sizes.
  • keyboard-shift-latched.png, keyboard-lower.png and keyboard-pressed.png: Shift tapped once (a latched rim and a hollow bar, every legend shifted), Lower tapped once (its keys bright, the keys it leaves out dim), and F held down with travel while another pointer hovers J.
  • keyboard-bar-hot.png: a debug frame, on the grab bar alone. The right laser lands two centimeters under the grab bar’s pill, so the bar is hot from the laser alone, grown and white with the frame lifted. What answers the laser is outlined in magenta, the hand’s hit area in cyan, and the laser’s dot is drawn in. The log line gives the band’s size in millimeters and degrees. keyboard-status.png: desktop output with an injector that cannot reach the focused window, its reason across the top. That injector refuses everything; no capture ever types into the desktop. keyboard-back.png: the panel’s back.
  • settings.png, settings-import-error.png (after importing a snippet with a misspelled field) and settings-interaction.png (scrolled to the Interaction and Haptics sections, with the Wrist grab and Panel grab rows) and settings-desktop-input.png (scrolled to the Desktop input section, whose grip + trigger row names the panel grab it gives way to). keyboard-face-grabbable.png: the right laser on a key with the panel grab on the whole unit, the whole keyboard tinted grabbable. keyboard-bars-unit.png and keyboard-bars-bar.png: the keyboard in each panel grab mode with nothing near, with no grab bar on the whole unit and the bar at rest on the grab bar alone. screens-grab-ready.png is the middle screen, on the whole unit, at four pixels per placeholder unit with the laser’s grab-ready dot where it lands; screens-bar-rest.png and screens-bar-ready.png are its bar over a dark backdrop on the grab bar alone, at rest and with the laser on its band. The log says for each whether there is a bar, whether the panel is grabbable and whether the dot is ringed.
  • settings-overscroll-rubberband.png, -stretch.png, -gapgrowth.png and -off.png: the pane dragged by SteamVR’s laser from near the top of its list until it shows 120 lp past the top in each overscroll style, and held there. A stretch takes its pull as it is, so its frame is a 120 lp pull; the log gives the pull, the overscroll and the stretch’s scale. settings-scrolling.png is the pane scrolled to the Scrolling section.
  • settings-fling-plot.png: how far a glide carries over time from releases at 50, 300, 1500, 3000 and 6000 lp/s, stepped frame by frame through the lists’ own simulation with the settings’ scroll.glide and scroll.deceleration, each curve ending where its glide stops.
  • settings-desktop.png, settings-desktop-small.png and settings-desktop-edited.png: the settings window’s view drawn offscreen at desktop density, in a 640×720 window on a 150% display (960×1080 pixels), in a 480×360 window at 100% where the view scrolls, and the first again after the accent was edited through the shared model.
  • settings-desktop-scrolled.png and settings-desktop-small-scrolled.png: the same two windows scrolled down by twelve wheel notches (delivered precise, so they neither glide nor count as a fast spin), which shows the pinned heading’s hairline and shadow with content under them. settings-desktop-autoscroll.png is the small window a quarter second after a middle click, the pointer 60 lp below the puck and the down chevron lit.
  • autoscroll-indicators.png: the autoscroll’s puck posed three ways at twice its size, vertical at rest, vertical moving down and four-way moving up and left. scrollbar-overlay-hover.png: a list with an overlay scrollbar after it faded, the pointer in the bar’s band bringing it back at full width. wheel-glide-plot.png: how far a notched wheel’s glide carries over time for one notch, five notches 250 ms apart and ten notches 30 ms apart, stepped through a real tree with the settings’ wheel values. settings-interaction.png scrolls its nine notches as a precise delta, so it lands in the frame it was turned.
  • gesture-approach-00.png, -50.png and -100.png: the hand widget as a snout tap comes in, driven through the host by scripted poses. The three frames are just inside the feedback distance, halfway through it, and a frame short of the tap. gesture-fired.png is the frame the tap fires: the flash, with the button card starting out. A log line under each gives the distance and the level shown.
  • gesture-debug.png: the gesture debug panel after a scripted session: a wrist flip that fires, a half flip, a snout tap that fires, and a second approach stopped halfway. gesture-ring.png is the ring its markers are drawn with.
  • mark.png: the dashboard thumbnail and tray icon.
  • Three standalone hand widgets isolating the universal clock: clock-all.png (every line on), clock-no-seconds-no-date.png (seconds and the date off) and clock-minimal.png (every line but the bare time off).
  • wrist-back-compact.png, wrist-back-interactive.png and wrist-back-monitors.png: the unit from behind in compact mode, in interactive mode and with the monitor bar out. Each is the back surface’s own image composed over a dark backdrop, since the plate is translucent. wrist-back-placement.png is the compact back with the watermark at 50%, to judge its fit by eye, and wrist-back-plain.png the compact back with the watermark off.

It runs the same app code as a live session, against an offline backend in place of SteamVR, with the hands as poses the capture sets, so each picture is exactly the image the compositor would have been handed. The clock is pinned to 10:09 so two runs write the same files.

The app keeps its own settings file, settings.json in the per-user config directory: %APPDATA%\HalcyonXrOverlay on Windows, ~/.config/HalcyonXrOverlay on Linux. It is written with every default on the first run. It is read again whenever it changes, so a value edited in a text editor applies without a relaunch, except renderScale. The live run’s log, latest.log, sits beside it; a new live run first keeps the one already there as previous.log, and a --capture run writes capture.log instead, so a capture after a headset session never erases what the headset saw.

KeyDefaultMeaning
dashboardPanetrueWhether the settings pane exists on the dashboard, so SteamVR’s own dashboard lists the app. The tray’s checkbox writes it, and so does the hand widget’s Settings button when settingsPanel.opensIn is dashboard.
settingsPanel.opensInoverlayPanelWhat the Settings cell opens: overlayPanel (the settings panel in the room) or dashboard (the pane on SteamVR’s dashboard).
settingsPanel.widthMeters0.6How wide the panel is in the world; its controls grow to the comfortable size for that width.
settingsPanel.scale1A factor on that width, set by scaling the panel with both hands; the controls keep the density widthMeters gives them and simply grow or shrink with it.
settingsPanel.position, .facing, .upin front, 70 cm aheadWhere the panel opens relative to the level head; moving the panel rewrites them.
startup.launchWithSteamVRfalseWhether SteamVR starts the app with itself. SteamVR’s own list holds the real switch; this mirrors it, and the tray’s checkbox writes it. Editing it while the app runs is pushed to SteamVR.
startup.connectWaitSeconds120How long a launch waits for SteamVR to be ready before it gives up.
startup.connectRetrySeconds2How often the wait tries again.
accent#C478B8The accent every tinted surface grows from (Studio Black’s default).
renderScale2Image pixels per logical pixel. Applies at the next launch.
clock.showSecondstrueWhether the trailing seconds run draws beside the hour and minute.
clock.showDatetrueWhether the bold month/day/year line draws.
clock.showMonthNumbertrueWhether the tiny day line’s two-digit day of the month draws.
clock.showWeekdaytrueWhether the tiny day line’s abbreviated weekday draws.
frameWaitMilliseconds100The longest the loop waits for SteamVR’s next frame before it polls anyway.
wrist.handleftThe hand that carries the hand widget: left or right. The other hand is always the opposite one.
wrist.widthMeters0.12The card’s width in the world.
wrist.position(-0.05, 0, 0.12)The card’s center in that hand’s controller frame (meters; +X right, +Y up, -Z where the controller points).
wrist.facing(-1, 0, 0)Where the card’s face looks, in the same frame: out of the back of the left hand.
wrist.up(0, 0, -1)Roughly where the card’s top points: toward the fingers.
dashboard.widthMeters2The pane’s width on the dashboard.
toggle.handleftWhich controller’s button switches between compact and interactive mode: left, right or either. With either, each hand counts its own taps, so one tap on each hand is not a double tap.
toggle.buttonlowerFaceThe button: lowerFace (A/X), upperFace (B/Y), triggerClick, gripClick, thumbstickClick or menu.
toggle.gesturedoubleTapdoubleTap or tap.
toggle.doubleTapWindow0.4The longest gap, in seconds, between a double tap’s first release and its second press.
toggle.maxPressSeconds0.35The longest, in seconds, a press may be held and still count as a tap. A longer press is a hold and cancels a double tap in progress.
toggle.bybuttonWhat toggles the mode (toggling by motion): any of button, snoutTap and wristFlip, written comma-separated ("button, snoutTap"). None reads as button.
toggle.snoutTap.facingMaxDegrees35How far the widget’s face may point from the eyes.
toggle.snoutTap.inFrontMaxDegrees30How far from the middle of the view the widget may be.
toggle.snoutTap.tapDistance0.13The widget this close to the eyes fires, in meters.
toggle.snoutTap.closeDistance0.3Within this a valid approach shows on the widget.
toggle.snoutTap.rearmDistance0.24After a fire, the widget must go back out past this before it can fire again; it must also start out past it.
toggle.snoutTap.approachConeDegrees30How far the motion may point from the eyes.
toggle.snoutTap.minApproachSpeed0.08The slowest closing speed that counts as approaching, in meters a second.
toggle.snoutTap.maxSpeed1.2Faster than this is a swing, never a tap.
toggle.snoutTap.maxLateralSpeed0.3The fastest it may move across the line to the eyes.
toggle.snoutTap.minApproachTravel0.06How far a valid approach must have come before it may fire, so a widget held close never fires.
toggle.snoutTap.approachGraceSeconds0.08How long an approach survives frames that break it before it is dropped. A fire always needs a valid frame.
toggle.snoutTap.cooldownSeconds0.8How long after a fire before another may fire.
toggle.snoutTap.velocitySmoothingSeconds0.04The time constant the widget’s velocity is smoothed over.
toggle.snoutTap.ringColor, .ringWidth#8FE3C0, 3The approach’s color and its rim and bar width, in the widget’s logical units.
toggle.snoutTap.fadeSeconds, .flashSeconds0.18, 0.4How long a broken approach takes to fade, and how long a fire’s flash lasts.
toggle.snoutTap.tick, .confirm0.008 s, 0.2; 0.04 s, 0.8The tick as the approach shows, and the tap on a fire, on the widget’s hand.
toggle.wristFlip.facingMaxDegrees40How far the widget’s face may point from the eyes, before and after.
toggle.wristFlip.inFrontMaxDegrees35How far from the middle of the view the widget may be.
toggle.wristFlip.minFacingSeconds0.15How long the widget must face the eyes before a flip may start.
toggle.wristFlip.flipDegrees120How far the forearm must roll out.
toggle.wristFlip.returnDegrees40How close to where it started it must roll back.
toggle.wristFlip.minSeconds, .maxSeconds0.3, 1.5The quickest and the slowest the whole flip may be.
toggle.wristFlip.maxTranslation0.12How far the controller may move during the flip, in meters.
toggle.wristFlip.cooldownSeconds0.8How long after a fire before another may start.
toggle.wristFlip.restDegreesPerSecond60A forearm rolling slower than this while facing the eyes is at rest, and the flip is measured from there.
toggle.wristFlip.rollAxis(0, 0, -1)The forearm’s axis in the controller’s frame.
toggle.wristFlip.cue, .confirm0.01 s, 0.3; 0.04 s, 0.8The tick as the roll passes the flip angle, and the tap on a fire.
toggle.debug.enabledfalseWhether the gesture debug view shows.
toggle.debug.widthMeters0.5The debug panel’s width in the world.
toggle.debug.position, .facing, .up(0.4, -0.05, -0.6), (-0.55, 0.05, 0.83), (0, 1, 0)Where the debug panel opens relative to the level head: ahead and to the right.
toggle.debug.historySeconds3How much history the graphs show.
toggle.debug.refreshSeconds0.033The shortest gap between two pictures of the panel.
toggle.debug.coneRingMeters0.08How far in front of the widget the facing ring sits.
toggle.debug.sphereRingDegrees45Where on the tap sphere its ring is drawn, in degrees round from the line to the widget.
toggle.debug.lineMeters0.002The markers’ line width.
interaction.revealSeconds0.18How long the button card takes to slide out or back.
interaction.tip(0, -0.02, -0.08)The poking fingertip, in the other controller’s frame.
interaction.hoverDistance0.03How close in front of the card, in meters, the fingertip hovers a button.
interaction.pressDepth0How close it must come to press; zero is touching the card’s face.
interaction.releaseDepth0.008How far back out a poke press must come before it lets go. It sits in front of the press depth, so a resting fingertip does not chatter.
interaction.behindLimit0.04A fingertip further than this behind the card counts as away rather than pressing.
interaction.grabRange0.07In unit mode, the other controller this close to the unit makes it grabbable.
interaction.grabRelease0.09It stays grabbable until the controller is further than this.
interaction.grabTintSeconds0.05How long the grabbable treatment and the scale-up take to come on or go off, on a cubic ease-out.
interaction.grabModeunitThe wrist grab: what the other hand takes hold of on the hand widget. unit (the whole unit, by coming near it) or bar (the grab bar below it, by the controller or the laser). Shown as “Wrist grab”.
interaction.panelGrabunitThe panel grab: what takes hold of a panel fixed in the room (the keyboard, the settings panel, the screens). unit takes it anywhere on its face, by the hand within grabRange with the grip or by the laser on it with the grip, and the panel has no grab bar at all; bar takes it by its grab bars alone. See grabbing a room panel.
interaction.twoHandGrabhandOffWhat the other hand’s grab does to a room panel one hand holds (two hands): handOff takes it over where it is and the first hand lets go; scaleRotate holds it with both, their span scaling it and their line moving and turning it. Shown as “Two-hand grab”.
interaction.twoHandMinScale, .twoHandMaxScale0.5, 3The smallest and largest a panel may be scaled with both hands, as a factor on its default size.
interaction.handLaserGrabRotatestrueWhether the laser’s grab of the hand widget’s bar holds it rigidly, turning it with the controller about the grabbed point as well as moving it. Off, the laser only translates it. A grip always moves and turns it.
interaction.roomLaserGrabRotatestrueThe same for a panel fixed in the room, such as the keyboard.
interaction.showPivottrueWhether dragging the hand widget shows what it is measured from: the laser’s dot on the tracked point of the widget’s own hand, with a thin beam from there to the widget’s pivot. A switch in the pane’s interaction section.
interaction.pivotDotMeters0.008The pivot marker’s dot, in meters across.
interaction.pivotLineMeters0.0015The pivot marker’s line where it leaves the hand, in meters wide; it tapers like the laser’s beam.
interaction.grabRimLift0.18How far each card’s rim lightens while grabbable, in OKLCh lightness. The rim’s width never changes.
interaction.grabFillLift0.035How far each card’s fill lightens while grabbable.
interaction.grabChroma1.15The factor on the rim’s and the fill’s own chroma while grabbable.
interaction.grabScale0.02How much the hand widget, the keyboard and the settings panel grow while grabbable: 2%, about the image’s center. It is the surface’s width that grows, never the layout. The screens never grow: see the cursor and the dot.
interaction.bar.restWidth72The grab bar’s width at rest, in the widget’s logical units (320 across).
interaction.bar.restHeight6Its height at rest; the pill is always fully rounded.
interaction.bar.restOpacity0.42Its white’s opacity at rest.
interaction.bar.hotWidth80Its width while grabbable: a little wider. It grows from its center.
interaction.bar.hotHeight10Its height while grabbable: most of the growth is here.
interaction.bar.hitWidth140The invisible area that answers the other hand for the pill.
interaction.bar.hitHeight28Its height.
interaction.bar.gap6The gap between the bottom of what is visible and the top of the hit area.
interaction.bar.laserHitHeight96The invisible band that answers the laser, from the bottom of what is visible down: 36 mm on both the hand widget and the keyboard, about 3.8° at the keyboard’s opening distance. The image grows to hold it.
interaction.bar.laserHitSpan1How much of the width of what the bar hangs below that band spans, centered: the clock card’s width on the hand widget, the frame’s on the keyboard. It never shrinks below the hand’s area.
interaction.bar.grabRange0.03The other controller this close to the hit area makes the bar grabbable. Tighter than grabRange, so a hand near the clock does not light the bar.
interaction.bar.grabRelease0.045It stays grabbable until the controller is further than this.
interaction.targetMeters0.021The smallest a control may be in the world on any surface. Each surface turns it into logical units through its own width (density).
interaction.targetGapMeters0.003The smallest gap between two controls in the world.
interaction.haptics.hover0.012 s, 0.35The tap when the fingertip comes onto a button (seconds, amplitude, frequency).
interaction.haptics.switch0.012 s, 0.35The tap when it moves straight onto another button.
monitorBar.styleconnectedHow the monitor bar sits on the clock card: connected (tucked behind its top edge, the button card’s mirror) or disconnected (a rounded bar of its own, monitorBar.gap above it).
monitorBar.gap6A disconnected bar’s gap above the clock card, in the widget’s logical units.
monitorBar.cellMinWidth88The narrowest a display’s cell is laid out, never below the widget’s target; more displays than fit in a row wrap into another row.
monitorBar.cellHeight72A display’s cell’s height, never below the target.
monitorBar.recenterWidth36The Recenter cell’s width at the end of the last row, never below the target.
monitorBar.recenterSheen0.035How much lighter the Recenter cell’s top edge is than its bottom (OKLCh lightness); 0 is flat.
displayCapture.captureCursortrueWhether the pointer is drawn into a captured display.
displayCapture.showBorderfalseWhether Windows 11’s yellow capture border may stay. Off asks Windows to leave it off; where Windows refuses, the capture’s status says BorderShown.
displayCapture.retainedFrames1How many shown pictures before the newest stay untouched. Each capture keeps this many plus two textures. Raise it only if sharedHandle ever tears.
displayCapture.poolBuffers2How many buffers Windows’ own capture pool keeps.
displayCapture.retrySeconds1How long a capture waits before finding its display again after the display went away or the capture failed.
displayCapture.sharingcopyHow a picture reaches SteamVR: copy hands it the Direct3D texture, which SteamVR copies at submission and draws sharp; sharedHandle hands it a DXGI shared handle the compositor reads directly, with no copy, and filters more softly.
interaction.haptics.leaveTaptrueWhether coming off a button onto nothing taps at all.
interaction.haptics.leave0.005 s, 0.15That softer, shorter tap.
interaction.haptics.leaveDeadSeconds0.025How long a leave waits, about two frames. Coming onto another button within it is one switch tap, so crossing the hairline channel between two buttons never reads as a gap. Zero feels every leave at once.
interaction.haptics.press0.015 s, 0.45A press landing on a button, before anything is clicked.
interaction.haptics.click0.03 s, 0.9A click, the strongest tap.
interaction.haptics.grab0.03 s, 0.7Taking hold of the unit, on the grabbing hand.
interaction.haptics.release0.015 s, 0.35Letting go of it: a lesser tap on the same hand.
interaction.haptics.grabLimit0.02 s, 0.3A pushed or pulled panel reaching the nearest or the furthest it may be, on the pushing hand.
laser.origin(-0.006, -0.015, 0.02)Where the beam leaves the right controller, in its frame; the left hand takes the mirror image.
laser.pitch-40Degrees the beam tips up from the controller’s forward; negative points it down.
laser.yaw5Degrees it turns toward -X: inward on the right hand, mirrored on the left.
laser.roll0Degrees it rolls about itself.
laser.widthMeters0.0025The beam’s width where it leaves the controller.
laser.endWidth0.55Its width where it meets the panel, as a fraction of widthMeters.
laser.pressedWidthScale2How much wider it is while the trigger holds a press.
laser.color#8CD9FFThe beam’s color.
laser.opacity0.95The beam’s opacity.
laser.maxLength5The farthest panel the laser reaches, in meters.
laser.dotMeters0.006The dot’s diameter where the beam meets a panel.
laser.pressedDotScale0.636The dot’s size while pressed, as a fraction of dotMeters (14/22).
laser.dotLiftMeters0.002How far the dot floats off the panel toward the controller, so it never shares the panel’s plane.
laser.grabRingScale2The grab-ready ring’s outer diameter as a multiple of the dot’s: a ring in the accent around the dot while the grip would take hold of the screens where the laser is. The dot inside keeps its size.
laser.grabRingWidth0.16The ring’s width, as a fraction of its outer radius.
laser.missGraceSeconds0.05How long a ray that only missed (left the covered area or the image) keeps the beam and dot where they were before hiding them; 0 hides at once. A laser the app switched off always hides at once.
dashboardFade.enabledtrueWhether panels dim while SteamVR’s dashboard is open.
dashboardFade.faintOpacity0.12The opacity a dimmed panel settles at.
dashboardFade.fadeOutSeconds0.2How long the dim takes after the dashboard opens; 0 is at once.
dashboardFade.fadeInSeconds0.25How long the panels take to come back after it closes; 0 is at once.
scroll.sticktrueWhether a thumbstick scrolls the list under its hand’s laser or fingertip.
scroll.stickHandeitherWhose thumbstick scrolls: either, left or right.
scroll.reverseStickfalseWhich way a stick scrolls, for the lists and the desktop wheel alike. Off, a stick pushed up scrolls toward the top, as a mouse wheel rolled forward does; on, the page follows the stick.
scroll.reverseTrackpadfalseWhich way a trackpad swipe scrolls, for the lists and the desktop wheel alike. Off, content follows the finger, so a swipe up scrolls toward the bottom, as on a phone; on, a swipe up scrolls toward the top.
scroll.stickDeadzone0.18How far a thumbstick must lean before it scrolls; the rest of its travel is the full range.
scroll.trackpadtrueWhether a trackpad (Index, Vive wand, Windows Mixed Reality) scrolls by swiping.
scroll.trackpadSensitivity1How far a swipe moves the list per unit of pad travel.
scroll.trackpadPixels360How far a list moves for a finger crossing the pad by one unit at sensitivity 1, in logical pixels; the pad spans two.
scroll.laserDragtrueWhether a laser’s trigger dragged across a list scrolls it, SteamVR’s laser on the settings pane included.
scroll.pokeDragtrueWhether a fingertip dragged across a list scrolls it.
scroll.dragSlop8How far, in logical pixels, a press travels before it becomes a scroll.
scroll.dragFromButtonstrueWhether a drag that starts on a button scrolls, letting go of the button without a click.
scroll.scrollbaralwaysThe settings view’s bar: always (a bar in a lane of its own, against the window’s edge) or autoHide (a thin bar over the content that fades out when the pointer and wheel are quiet). Both the dashboard pane and the desktop window follow it.
scroll.overscrollrubberBandWhat a list shows past its ends: rubberBand (iOS), stretch (Android 12), gapGrowth (the Vita) or off.
scroll.flingStrength1The factor on a release’s speed.
scroll.deceleration600The glide’s constant friction, in logical pixels a second squared: higher stops a gentle release sooner.
scroll.glide0.8The glide’s drag per second: the share of its speed a fast fling loses each second. Lower glides longer.
scroll.minFlingVelocity50How fast a release must be to glide at all, in logical pixels a second.
scroll.maxFlingVelocity8000The fastest a glide may start.
scroll.rubberBand0.55The rubber band’s constant (iOS’s): a pull x shows (1 - 1 / (x c / d + 1)) d past the end, d the list’s height.
scroll.springStiffness256How hard a rubber band or growing gaps spring back, per unit mass (16 rad/s, critically damped).
scroll.stretchGain1The factor on Android’s stretch, which at one grows the content by at most about 3%.
scroll.gapGrowth1The factor on how far the growing gaps spread the rows.
scroll.tapStopsFlingtrueWhether a press on a gliding list stops it; a press that stops a fast one presses nothing else.
scroll.stickSpeed900A full lean’s speed when the stick is first pushed, in logical pixels a second.
scroll.stickAcceleration1100How fast a held stick’s speed grows, in logical pixels a second squared; zero holds it steady.
scroll.stickMaxSpeed2400The fastest a held stick scrolls.
scroll.stickCurve2The curve from lean to speed: one is linear, two is gentle near the center.
scroll.stickReleaseGlidestrueWhether a stick let go eases out on a glide rather than stopping dead.
scroll.smoothWheeltrueWhether a notched mouse wheel glides a list rather than jumping it; a touchpad always moves at once.
scroll.wheelStep48How far one wheel notch scrolls a list, in logical pixels (16 to 200 in the pane).
scroll.wheelGlide0.3How long one notch’s glide takes, in seconds; a notch arriving mid-glide starts a new one from where the list is.
scroll.middleAutoscrolltrueWhether a middle click on a list starts autoscrolling it toward the pointer.
grabDepth.enabledtrueWhether the grabbing hand’s thumbstick and trackpad push and pull what it holds, instead of scrolling.
grabDepth.sticktrueWhether the thumbstick pushes.
grabDepth.trackpadtrueWhether a trackpad swipe pushes.
grabDepth.invertfalseWhether pushing up (or swiping up) pulls the panel in.
grabDepth.deadzone0.15How far the stick must lean before it pushes.
grabDepth.speed0.8A full lean’s speed when first pushed, in log units a second (one unit is a factor of e).
grabDepth.acceleration1How fast a held stick’s speed grows, in log units a second squared.
grabDepth.maxSpeed2.4The fastest a held stick pushes, in log units a second.
grabDepth.curve1.6The curve from lean to speed: one is linear, two is gentle near the center.
grabDepth.minMeters0.2The nearest a panel may be pulled to the controller.
grabDepth.maxMeters8The furthest a panel may be pushed from the controller.
grabDepth.trackpadGain1.2How far a swipe pushes per unit of pad travel, in log units; the pad spans two.
grabDepth.easeOuttrueWhether a stick let go, or a swipe lifted while moving, eases out rather than stopping dead.
grabDepth.glideDrag3The ease-out’s drag per second.
grabDepth.glideFriction0.8The ease-out’s constant friction, in log units a second squared.
grabDepth.limitTicktrueWhether the hand feels a tick when the panel reaches the nearest or the furthest it may be.
keyboard.layoutpreonicThe keyboard’s layout: a built-in id or an imported one.
keyboard.keyStyleflushflush (flush tiles, subtle rims) or inked (separate tiles with a bright rim and a dark ring).
keyboard.outputinjectinject types into the desktop’s focused window; echo types only into the panel’s own text field, for trying it safely.
keyboard.traveltrueWhether a pressed key’s face travels down.
keyboard.travelDistance1.5How far, in the panel’s logical units.
keyboard.travelSeconds0.04How long it takes, each way.
keyboard.inkedChannel8The channel between two inked faces.
keyboard.flushChannel4The channel between two flush faces.
keyboard.frameEdge12From the frame’s rim to the outermost faces, in both styles.
keyboard.unitMillimeters0.375A logical unit’s size in the world: the hand widget’s density. A layout’s pitch in millimeters sets its keys’ size.
keyboard.scale1The panel’s size in the world as a factor, set by scaling it with both hands: every key grows or shrinks with it and the layout stays as it is.
keyboard.resizeSeconds0.8How long a layout switch’s resize takes, on an elastic-out curve.
keyboard.fadeOutEnd0.3Where in it the old keys have faded out.
keyboard.fadeInStart, keyboard.fadeInEnd0.12, 0.52Where the new keys fade in.
keyboard.legendFadeSeconds0.12A legend’s cross-fade when a layer or Shift changes it.
keyboard.doubleTapWindow0.4Two taps of a modifier or layer key within this many seconds lock it.
keyboard.repeatDelaySeconds, keyboard.repeatIntervalSeconds0.5, 0.035A held key repeats after the delay, once per interval.
keyboard.echoWidth, keyboard.echoHeight, keyboard.echoGap920, 60, 24The text field (or status line) above the frame, and its gap.
screens.radiusMeters1.5The radius of the cylinder the screens wrap around the head, and how far ahead the wall opens.
screens.curvature11 curves every screen onto that cylinder; 0 is a flat, tiled wall; between, a cylinder of radiusMeters / curvature that still touches the wall’s middle.
screens.pixelMillimeters0.4One desktop pixel’s size in the world: a 3440-pixel display is 1.376 m along its arc.
screens.gapMeters0.02The straight-line gap between two screens that touch on the desktop.
screens.heightMeters-0.1How far above eye height the wall’s middle opens and recenters; negative is below.
screens.recenterOnOpen"ifOutOfView"Whether the wall is put back in front of the head as interactive mode opens: "off", "ifOutOfView" (only when no screen is in view) or "always". Also the Screens section of the settings panel.
screens.viewConeDegrees38The half-angle around where the head looks inside which a point of a screen counts as in view.
screens.facingDegrees80The widest angle between a screen’s front and the direction to the head at which a sample point still counts as facing it. A wall in view with its back to the head is recentered.
screens.visibleFraction0.25The share of a screen’s sample points (a 5 by 3 grid over its curved surface) that must be in view for the screen to count as seen.
screens.viewMaxMeters6The farthest a sample point may be and still count as in view.
screens.barUnitMillimeters1.2A logical unit of the screens’ grab bar in the world, so the bar keeps the shared interaction.bar.* sizes but reads at the wall’s distance.
screens.position, screens.facing, screens.up(0, -0.1, -1.5), (0, 0, 1), (0, 1, 0)Where the wall’s origin (the middle of the screens on) was last left relative to the level head. Written on every change; read only at launch.
screens.on[]The ids of the displays that were on. Written on every change; read only at launch, and restored only if every id still exists.
screens.layoutSeconds0.25How long the screens take to slide into their slots when one turns on or off, on a cubic ease-out; 0 snaps them.
screens.barSiblingGlow0.35How strongly the other grab bars light, as a share of the grabbable treatment, while one is grabbable or held; they all carry the wall.
screens.scale1The whole wall’s size as a factor, set by scaling it with both hands: the radius, every screen and every gap together, so its shape and curvature stay; the bars keep their size.
desktopInput.enabledtrueWhether the lasers reach the screens: the cursor, the buttons and the wheel (using the desktop).
desktopInput.hoverMovesCursortrueWhether the real cursor follows a laser that is only pointing at a screen; off, it moves only for a press or the wheel.
desktopInput.armRightClickByTouchtrueWhether a thumb resting (touching, not pressing) on the arming zone or on the upper face button (B or Y) makes the trigger a right click.
desktopInput.armZoneupperPadWhere on the trackpad a resting thumb arms: upperPad (above armZoneMinY) or wholePad.
desktopInput.armZoneMinY0.3The pad height, from -1 at the bottom to 1 at the top, above which a touch arms when the zone is upperPad.
desktopInput.trackpadClickIsRightClicktrueWhether pressing the trackpad in while pointing at a screen is a right button press of its own, held for as long as the pad is.
desktopInput.stickClickIsRightClickfalseWhether pressing the thumbstick in while pointing at a screen is a right button press of its own.
desktopInput.gripTriggerIsRightClickfalseWhether pulling the trigger with the grip held is a right click. Ignored while interaction.panelGrab is unit, since the grip then takes hold of the screen under the laser; the log says so once.
desktopInput.armedDotColor#FFA64DThe laser dot’s color while a right click is armed, #RRGGBB: by default the beam’s complementary hue.
desktopInput.dragSlopDegrees0.75How far, in degrees of the laser’s swing, a held trigger may wander before the press follows it as a drag. An angle, so a far screen clicks as easily as a near one.
desktopInput.scrollSpeed0.4The factor on the wheel against the lists’ own stick and trackpad speed (scroll.stickSpeed, scroll.trackpadPixels, one notch per scroll.wheelStep).
keyboard.position, keyboard.facing, keyboard.up(0, -0.3, -0.45), (0, 0.55, 0.83), (0, 1, 0)Where the panel opens relative to the head as it stands level (+X right, +Y up, -Z ahead). Letting go of a moved panel rewrites them.
log.grabDiagnosticstrueWhether a held unit writes the [grab] line: the grabbing controller’s yaw, pitch and roll, the widget hand’s, the offset the grab produced, the pose that puts the unit at in the world, and the placement SteamVR holds for the surface, read back.
log.grabDiagnosticsSeconds0.5How often, in seconds, a held unit writes it. The first comes with the grab.
log.laserDiagnosticstrueWhether each laser hide, re-show and absorbed miss writes a [laser] line with its reason and how long the beam was gone.
log.laserDiagnosticsSeconds0.25The shortest gap between two [laser] lines for one hand; the lines between are counted into the next (+N more since the last line).
log.poseDiagnosticstrueWhether the log writes the [pose] lines: the tracking universes in play each time they change, and each controller’s state each time it changes (tracked or not, which device, whether the pose action reads). An untracked controller is written once, then silent until it tracks. See one tracking origin.
log.poseSpaceCheckSeconds1How often, in seconds, the universes are read for a change.
log.poseSampleSeconds5How often, in seconds, each controller is read to look for a change.
log.poseSamplesfalseWhether the position samples (device pose beside pose action, D mm apart) are written while nothing changes: when the two disagree by more than log.poseSampleApartMillimeters, or once per log.poseSampleQuietSeconds when they agree. For hunting a hand drawn far from its controller.
log.poseSampleApartMillimeters10How far apart, in millimeters, the two poses must be for every sample to be written.
log.poseSampleQuietSeconds60The longest gap, in seconds, between two samples while the poses agree.
log.gestureDiagnosticstrueWhether every snout tap and wrist flip that fires, and every one that nearly did, writes a [gesture] line with its reason.
log.gestureDiagnosticsSeconds0.5The shortest gap between two near-miss lines for one gesture; the lines between are counted into the next. Fires are always written.
look.backPlateOpacity0.8The opacity of the plate a placed pane shows from behind.
look.backWatermarktrueWhether that plate carries the Molten Labs sign.
look.backWatermarkOpacity0.05The sign’s opacity: white at this alpha, blended in linear light (see the back).
look.backWatermarkWidth0.55The sign’s width as a fraction of the plate’s.
look.backWatermarkHeight0.6The most of the plate’s height the sign may take; a short plate shrinks it to this, its aspect kept.

The keys keep the name wrist; most people wear the widget on the back of the hand, which is why the interface calls it the hand widget. The offsets are a first guess, meant to be tuned in the headset, and grabbing the unit rewrites them. wrist.position names where the clock card’s center sits; the surface is shifted from it by half the difference between the room above the clock (the monitor bar’s) and everything below it (the button card, and the bar’s room in bar mode), so opening and closing either never moves the clock, and neither does switching grabMode, monitorBar.style or a display coming and going. Changing wrist.hand in the settings pane mirrors the placement across the controller’s YZ plane, so a card on the back of the left hand lands on the back of the right; editing the file changes only what it names. wrist.hand and the toggle keys are written in camelCase and read in any case.

The dashboard tab opens a settings pane, laid out top to bottom:

  • Hand widget: which hand carries it, and whether the monitor bar is connected or disconnected.
  • Placement: the widget’s offset from its controller, as a position in meters and a rotation in degrees, with Copy, Import and Reset to defaults.
  • Toggle binding: every toggle.* key.
  • Interaction: the fingertip’s offset per axis, the hover distance, the grab range, the grab tint’s length, the wrist grab and the panel grab (each whole unit or grab bar), the two-hand grab (hand-off or scale and rotate) with its smallest and largest scale, the grab scale, the bar’s grab range, whether a laser’s grab turns the hand widget and whether it turns the room panels, the bar’s hit area for the hand and its band for the laser, and the bar’s rest size, rest opacity and grabbable size.
  • Grab depth: whether the grabbing hand’s stick and trackpad push and pull what it holds, invert, the dead zone, speed, curve, acceleration, top speed, easing out, the trackpad gain, the nearest and furthest distance and the tick at the limits.
  • Haptics: each tap’s strength and length, grab and release included, the grab limit’s too, the leave tap on or off, and the leave dead time.
  • Laser: the beam’s origin per axis and its pitch, yaw and roll, its width, its color as red, green and blue and its opacity, its reach and the dot’s size.
  • Scrolling: the smooth wheel (on, distance per notch, glide time) and middle-click autoscroll (on), the overscroll style (iOS, Android, Vita or Off) with the stretch’s and the gaps’ strength, the fling’s strength, deceleration and glide drag, the drag distance, whether the laser and the fingertip drag lists, whether drags start on buttons, whether a press stops a glide, and the thumbstick (on, reverse, hand, dead zone, speed, acceleration, top speed, easing out) and trackpad (on, reverse, sensitivity). The Settings panel section chooses where Settings opens and how wide the panel is.
  • Keyboard: the layout picker (the built-ins, the Preonic first, then anything imported), Import from clipboard, the key style, key travel and the output (Desktop or Text field only).
  • Clock: switches for the clock’s four parts — seconds, the date, and the tiny day line’s month number and weekday.

Every edit in the pane is written to settings.json at once. An edit made in the file reaches the pane (and the widget) through the same reload, so the two cannot disagree.

The same settings view also opens as an ordinary desktop window: click the tray icon, or choose Settings from its menu. It is the dashboard pane’s own widget, built from the same host and the same model at UiDensity.Desktop and laid out at the display’s content scale, so it looks like normal desktop UI at 100%, 150% or a Retina 200%, while the pane keeps its own scale in the headset. An edit in either place reaches the other on its next frame through the model, and so does an edit in settings.json.

  • The pointer takes the shape the view asks for, as it does in the engine: the hand over buttons and toggles, the I-beam over text fields, resize arrows over resize edges, the arrow elsewhere. It is one call per frame to CursorShapes from Halcyon.Sdl, which the engine host uses too. The headset panels have no OS pointer and are untouched.
  • The window is resizable (down to 360×240 logical pixels; 640×720 the first time), and its size and position are remembered in window.json beside settings.json, in logical pixels so a different display scale keeps the same shape. A remembered position on a monitor that is gone is dropped and the system centers the window.
  • Closing it only hides it; the app keeps running in the tray. Another click on the icon brings the window forward, restoring it if it was minimized.
  • The view follows the edge while it is dragged. On Windows the OS runs a modal loop for a resize that never returns to the app’s own loop, so the window also draws from an SDL event watch (SDL_AddEventWatch, LiveRedraw): each new drawable size or uncovering lays the view out at that size and presents it, one frame per event, and an event that arrives while a frame is already being drawn waits for the queue instead of entering it twice.
  • Nothing is drawn while nothing changes: the view is rasterized only when the model, the pointer or the size changed, and an idle window costs no GPU time.
  • Input is the mouse (move, buttons, wheel) and Escape. The settings view has no text fields, so the keyboard has nothing to type into yet.
  • A notched wheel glides the view (scroll.smoothWheel): each notch adds scroll.wheelStep to where the glide is going and eases out over scroll.wheelGlide, never slower than the view already moves, so a fast spin gathers speed and a slow notch stays gentle. A touchpad, or any wheel SDL reports in fractions of a notch, moves the view at once.
  • A middle click on the view autoscrolls it (scroll.middleAutoscroll): a puck appears at the click, the view moves toward the pointer faster the further it is (still within 14 lp of the puck), the chevron it moves toward lights in the accent, and the pointer turns to the arrow it moves along. A second middle click, a left or right click, Escape, the wheel, the window losing focus or the pointer leaving the window ends it; holding the middle button past the puck and letting go ends it too. A click that ends it does nothing else.
  • Every panel in the headset takes the same wheel settings: SteamVR’s discrete scroll glides, its smooth scroll moves at once.

The window is plain SDL3 with its own renderer. The view is rasterized on the app’s one device by a second rasterizer baked for the display’s scale, read back and presented through SDL, so there is no second Vulkan instance and no surface; the headless device the overlay runs on has no swapchain extension, and this keeps it that way. The engine’s SdlWindow stayed where it is: it is bound to Vulkan surfaces and the engine’s logging, and the app needs neither.

Copy puts the widget’s placement on the clipboard as a small JSON object, and Import reads one back. This is the format for sharing per-controller default placements:

{
"hand": "left",
"position": { "x": -0.05, "y": 0, "z": 0.12 },
"rotation": { "pitch": 0, "yaw": -90, "roll": 90 },
"widthMeters": 0.12
}
  • position is the card’s center in the controller’s frame, in meters, as in wrist.position. Each axis must be within 1 m.
  • rotation is in degrees, applied as yaw about +Y, then pitch about the turned +X, then roll about the turned +Z (Quaternion.CreateFromYawPitchRoll’s order). The card’s face is its +Z and its top is its +Y. The example is the default: facing out of the back of the left hand with its top toward the fingers.
  • hand is the hand the placement was made on. A snippet from the other hand is mirrored onto the current one. Without it, the snippet is taken as the current hand’s.
  • widthMeters is optional; without it the current width stays. It must be between 0.02 and 1.

position and rotation are required, a misspelled field is refused, and a refused snippet changes nothing: the pane shows why under the buttons. The clipboard is SDL3’s, so it works on every desktop the app runs on.

The cards. Compact mode shows the clock alone on one card, 112 logical pixels tall. Interactive mode leaves that card exactly where it is and slides a second card out from under its bottom edge: narrower, centered, and the same material as the clock card one step down (its bezel Toned(-0.04, ×1.25): outer ring, rim and fill all darker and more saturated by the same OKLCh step). Its face is split into three cells, Desktop, Keyboard and Settings, each a line-drawn mark over its label, filling the card edge to edge with 2-unit channels between them cut down to the card’s outer ring. The middle cell is square; Desktop rounds only its bottom-left corner and Settings only its bottom-right, at the card’s inner radius (its 8-unit radius less its two 1-unit rings), so the corners are concentric with the card’s outline, and a cell’s hover and pressed fills keep exactly those corners. The cells are 64 units tall, or the density’s target if that is larger. The slide animates its height, its offset and its opacity together, clipped so the card comes out from under the clock rather than over it. Both cards and the monitor bar are drawn into one image sized for everything open, transparent where something is closed (below the clock in compact mode, above it while the monitor bar is in), so the unit has one transform and nothing to keep in step.

On states. Each of the three buttons is a switch as well as a button: clicking switches it on or off, and on is its own state, apart from the pointer. An on cell takes the accent’s container tone (a step lighter and more colored than the card), brighter ink and mark, and a short accent line under its label; it keeps the cell’s concentric corners, and hover and press still show over it (the on fill lifted, and a deeper accent while pressed). This is Halcyon’s Button.IsOn with an OnPaint, the same notion Segmented and Dropdown mark their selection with. Desktop opens the monitor bar. Keyboard is on while the keyboard panel is open, however it was closed. Settings shows whether the settings pane is showing: on while SteamVR’s dashboard is open on this app’s tab (IsDashboardVisible and IsActiveDashboardOverlay, read again whenever the dashboard opens or closes or the pane is shown or hidden), so closing the dashboard or switching tabs turns it off. Clicking it while off opens the dashboard on the pane. OpenVR has no call that closes the dashboard (the only way an app does it is a virtual controller driver pressing the system button for it), so clicking Settings while on leaves it on and logs that the dashboard must be closed by hand.

The monitor bar. While Desktop is on in interactive mode, a bar comes up from behind the clock card’s top edge, the button card’s mirror: the same material, on the same reveal (revealSeconds, cubic-out, height and opacity together). It holds one cell per display, cut into the bar like the buttons, with only the top outside corners rounding when connected and all four outside corners when disconnected. Each cell shows a monitor with the display’s number in it, its name (ellipsized), its resolution, and a small dot on the primary display. Each cell is a switch of its own: it turns that display’s screen on or off and logs the display’s id and name (Display on: \\.\DISPLAY1 (DELL U2723QE)). At the end of the last row, a narrow icon-only Recenter cell (a ring with four ticks in the on-state accent, no label) puts the screens back in front of the head. It is an action, so it has no on state: a dark well in the bar’s own tone with a faint top-to-bottom sheen, and hover and press answer like any cell. Its width is monitorBar.recenterWidth, never below the target, so it stays a comfortable laser and fingertip target. When the grid wraps it ends the last row (seven displays: rows of three and two, then two plus Recenter), and the display cells in that row share the rest of the width, so they can run narrower than cellMinWidth there. Cells never go below monitorBar.cellMinWidth or the widget’s target, so more displays wrap into balanced rows (seven make rows of three, two and two), and with no displays the bar is one cell that says “No displays found”. The image always keeps the bar’s room above the clock card, so the clock never moves as the bar opens; a display change that needs another row makes a new image and surface, and the placement shifts with it so the clock stays put. The displays come from Halcyon.Desktop’s DisplayCatalog, read once at start and again on the app’s loop after its Changed event, which may fire on any thread. Until a desktop implementation lands for the running system, the catalog lists nothing and the bar says so.

Poking. The other hand’s fingertip (interaction.tip, an offset from that controller’s pose) is projected onto the card. Within hoverDistance in front of it, the fingertip hovers whatever button it faces. Pushing through the card’s face presses; the press lets go once the fingertip backs out past releaseDepth. Pulling the trigger while hovering also presses, until the trigger is let go. A button clicks on release only if the fingertip is still on the button that took the press; a press dragged onto another button, or off the card, clicks nothing. A fingertip that arrives from behind the card, or beside it already level with it, never presses on the way in.

The laser. The pointing hand (the one without the widget) has a laser of its own. It shows only in interactive mode, and only while its ray meets the part of the unit that is drawn; pointing anywhere else, or switching to compact mode, hides it. Where the ray meets the card a dot lies on its face. The trigger is the laser’s button with a mouse’s semantics: pulling it over a button shows the button pressed, letting go on the same button clicks it, and a press dragged off the button, or off the card, clicks nothing. A trigger already held when the ray arrives never presses. The laser hides while the fingertip is near enough to hover or press, and while the unit is held, so one hand only ever has one pointer: close in, the fingertip; further out, the laser. That needs no new gesture and no choice of mode, and the handover keeps the button under both lit, because a pointer leaving is always applied before the one arriving.

Scrolling with the thumbstick and the trackpad. The controllers’ analog input reaches the app through SteamVR Input beside the buttons, without taking anything from the game: a vector2 action per hand for the thumbstick and for the trackpad, and a boolean for a finger resting on the trackpad. The default bindings follow SteamVR’s own bindings for its dashboard (vrcompositor_bindings_knuckles.json): a joystick’s position, a trackpad’s position and touch. Index and Windows Mixed Reality bind both, Touch only the thumbstick and the Vive wand only the trackpad, and the trackpad’s existing click regions keep their face-button meaning. The desktop’s right click adds two boolean actions per hand beside them, bound separately from every existing action: TrackpadClick (the pad pressed in: Index, Vive wand and WMR) and RightClickArm (the touch of B or Y: Index and Touch; the wand and WMR have no such button, so their pad zone does the arming). A thumbstick’s lean is a held scroll velocity (after the dead zone, rescaled to the full range) and a trackpad swipe is a stream of positions the scrolling list turns into a flick; each goes to the pointer that hand’s laser or fingertip is on, and a hand pointing at nothing reaches nothing: its stick never scrolls what the other hand points at, so walking with one stick leaves the other hand’s target alone. Leaning a stick never presses or clicks: the thumbstick click stays its own button. While the dashboard owns the controllers both read at rest, and a finger already on a trackpad when they come back is not a touch until it lifts. The app only routes the intent; PanelScrollSink takes it (OverlayHost.ScrollSink), finds the panel the pointer is on, and hands the stick to Halcyon’s UiTree.ScrollVelocity and the swipe to its TouchpadBegin, TouchpadMove and TouchpadEnd at the pointer’s position. A stick pushed up scrolls toward the top, as a mouse wheel rolled forward does, while a swipe up drags the content up, as on a phone (scroll.reverseStick and scroll.reverseTrackpad flip them); both read the one pair of calls in ScrollSettings (StickToward, TrackpadToward), so the lists and the desktop wheel cannot disagree. A stick or a swipe stays with the panel it began on until it ends.

Dragging lists. Every list in the app — the settings pane, a dropdown’s options, anything a panel holds later — scrolls the way a list does on a phone, with the laser’s dot as the finger: press the trigger on empty space or a label and drag, and the content follows the dot one to one. SteamVR’s own laser does the same on the settings pane, since its trigger reaches the pane as a mouse button; the app reads every pointer but the settings window’s desktop mouse as a laser or a fingertip (OverlayPointers.KindOf). Past scroll.dragSlop the drag locks to the axis it moved along and the press lets go without a click, so a drag that starts on a chip or a button scrolls instead of choosing (scroll.dragFromButtons); a slider, a text field and the scrollbar keep their own drags. A row lights under the press and goes dark as the press becomes a scroll. Let go fast and the list glides on, slowing smoothly for two to three seconds from a fast flick; let go gently and it stops within a fraction of a second; let go after holding still and it stays put. A press on a gliding list stops it. Past either end the list shows its overscroll style — the rubber band follows the pull at 55% and less the further it goes, the stretch holds the pulled edge and scales the rest, the Vita’s gaps open between rigid rows — and springs back on release; a fling that reaches an end shows one sized by its speed. The physics are Halcyon’s own (ScrollPhysics, see Halcyon’s README), so the engine’s touch screens move the same way, and scroll.* is how the app tunes them; every panel takes them when the settings change.

The default alignment is mltn’s AudioLabsApp laser. That app draws its beam along the OpenXR aim pose (Assets/Scripts/VR/VRControllerLaser.cs), and SteamVR derives that pose from the controller’s tip render-model component; for an Index controller (valve_controller_knu_1_0_right.json) it is 6 mm inward, 15 mm down and 20 mm behind the raw pose this app reads, pitched 40° down and turned 5° inward. Touch controllers’ tips sit slightly lower and pitch about 37° to 39°, so the defaults are close there too, and laser.* tunes the rest. The look is the same app’s: a 2.5 mm beam tapering to 55% that doubles in width while pressed, in its hover color, and a near-white dot with a dark ring (its in-panel cursor). The dot is 6 mm, about a button label’s height, rather than the 12 mm of that app’s free-air sphere, which hides a whole label on a 12 cm card; it shrinks while pressed.

Grabbing. grabMode picks what the other hand takes hold of. In unit mode, the default, the other controller within grabRange of the unit makes it grabbable, and it stays so until the controller is past grabRelease. In bar mode a pill hangs centered below whatever is visible, white at bar.restOpacity, and follows the button card as it slides; only its hit area (bar.hitWidth by bar.hitHeight, bar.gap below the unit, never over a button) answers, within bar.grabRange, and the rest of the unit ignores the other hand. The laser makes the bar grabbable too, over a much bigger band (bar.laserHitHeight tall from the bottom of what is visible, bar.laserHitSpan of its width), because a ray is aimed by a wrist from a distance. What the unit covers always wins, so the band never takes a ray off a button or a key. The laser coming onto the band taps the pointing hand (haptics.hover) and its dot lands there like on any surface. Grabbable either way, both cards take the same treatment in grabTintSeconds on a cubic ease-out (fast at first, settling in): each rim lightens by grabRimLift and each fill by grabFillLift, both with grabChroma times their color, and no ring changes width. The whole unit also grows by grabScale about the image’s center, on the same curve; that is the surface’s width in the world animating, never a new layout, so nothing is drawn again for it. In bar mode the pill grows from its center to bar.hotWidth by bar.hotHeight (mostly taller, a little wider, always fully rounded) and turns opaque white, and while the unit is held it takes a pale accent tone. Squeezing the grip while grabbable takes hold, with a tap on the grabbing hand (haptics.grab): the unit follows the other hand rigidly, and for the length of the hold it is latched to the grabbing hand, so SteamVR keeps it perfectly still in that hand at scan-out however the widget hand moves or turns. Its offset on the widget hand’s anchor is worked out every frame from the same frame’s poses. Letting go taps that hand again, more lightly (haptics.release), latches the unit back to the widget hand at the offset the release frame’s poses give, which is exactly where it was, and saves that offset through the settings model, so the pane’s placement section and settings.json show it at once.

The pivot. While the unit is held, the laser’s own dot marks the point its offset is measured from, the tracked point of the widget hand’s controller, and the laser’s beam runs thinly from there to the unit’s pivot, the top card’s center the offset places. Both are latched to the widget hand, so the dot stays on it at scan-out. It is the laser’s pair of surfaces and pictures (LaserSurfaces), shown by PivotMarker, and it hides the moment the hold ends. interaction.showPivot switches it off. Grip means grab and a tip contact or the trigger means press, so a held unit is never pressed.

The dot is kept off blinking three ways. It draws at sort order 1001 and the beam at 1000, over every panel (all 0), so nothing sorts above it; it floats laser.dotLiftMeters toward the controller rather than lying exactly on the panel’s plane; and a ray that only misses for a moment (an edge, a hand tremor) leaves the beam and dot where they were for laser.missGraceSeconds before hiding them. A laser the app itself switched off (compact mode, the dashboard, the fingertip near, a grab) hides at once. The log’s [laser] lines say which of these happened: hid after N frame(s) / M ms without a beam: <reason>, missed for N frame(s) … and came back inside the grace; nothing hid, and showed again after M ms hidden, with the reason one of: the controller is untracked, nothing was offered to point at, the ray met an image outside the part that answers, or the ray met no surface. log.laserDiagnostics switches them off.

While the unit is held the log carries a [grab] line every log.grabDiagnosticsSeconds, with every link of the chain in it: Grabbing hand yaw … pitch … roll … at (…), the controller holding it; widget hand …, the controller it hangs from; offset …, what the grab produced on that hand’s anchor; unit in the world …, the two composed; and runtime holds Latched on RightHand, …, the device-relative placement read back from SteamVR, which names the grabbing hand during a hold. A twist of the grabbing hand that shows in its own angles and in the offset’s but not in the headset is the compositor’s to answer for; one that shows in the hand’s angles and not the offset’s is the grab math; one that shows in neither is the pose read. log.grabDiagnostics switches the line off.

Pushing and pulling a grabbed panel. While a hand holds something (the hand widget by the other hand, the keyboard, the screens’ bar), that hand’s thumbstick and, on Index and Knuckles, its trackpad move the panel along the line from the controller to it, keeping the grab rigid and the panel’s turn as it is. Pushing the stick up sends the panel away and pulling it down brings it in; a trackpad swipe moves it as the finger moves, and a swipe lifted while moving carries on and slows to a stop. The speed is in log space: a second of push multiplies the distance by a fixed factor, so a near panel moves finely and a far one quickly, the same at any frame rate. The stick runs the same speed curve, dead zone and acceleration that scrolling uses (ScrollPhysics.StickSpeedAt) and eases out by the same glide (FlingSimulation); the swipe uses the same velocity tracker. The distance stays between grabDepth.minMeters and grabDepth.maxMeters (a panel grabbed closer than the minimum can still be pushed out but not pulled in), and the hand feels a tick on arriving at either end. While a hand holds a panel its stick and trackpad go to the push and not to scrolling; once it lets go they return to the scroll sink. Each hand drives what it holds.

In bar mode the laser can carry the unit too: the trigger or the grip while the laser is on the bar’s band takes hold, with the same taps. The point the ray met stays at that distance along the ray, and by default the unit turns with the controller about that point, rigidly, as a grip holds it. handLaserGrabRotates and roomLaserGrabRotates (switches in the pane’s interaction section) choose per anchor kind, the hand widget and the panels fixed in the room; off, the laser only translates the unit, which keeps the orientation it had on its anchor. The math is XrRayGrab in Halcyon.Xr. Which grab takes hold is decided when the button closes: the controller within bar.grabRange of the bar is a hand grab, and otherwise the laser on the bar is a laser grab, even when the grip closed it. The log says which with Hand widget held by …. Like a grip’s hold, the unit is latched to the grabbing hand while held and its offset on the widget hand’s anchor is worked out every frame; letting go of the button that took hold latches it back at the release frame’s offset and saves it. While the laser holds the unit its beam stays on it.

Grabbing a room panel. interaction.panelGrab is the wrist’s grabMode for everything fixed in the room: the keyboard, the settings panel and the screens, and any room panel that comes later. In unit, the default, a panel is taken anywhere on its face, two ways: the other controller within grabRange of what the panel covers makes it grabbable and the grip takes hold (the same reach test the wrist uses), or the laser on the face takes hold with the grip. The trigger never grabs anything in unit: it stays a click on the panel’s UI, and the grip never clicks. bar takes the panel by its grab bars alone, with the trigger or the grip. A screen’s face is the whole screen, and grabbing any of them carries the whole wall like its bars do; with unit the screens take the laser even when desktopInput.enabled is off, so they can be grabbed. All of it is the one WristInteraction the hand widget runs, once per hand per panel (RoomGrab), with faceGrab on; the scale-up is the one GrabScale the wrist uses, so the keyboard and the settings panel show the wrist’s own feedback: the grabbable tint and the grab scale growth, the taps on the laser coming onto a part, on taking hold and on letting go, the stick’s depth push and pull, and latching to the grabbing controller while held. The grip belongs to the grab here, so desktopInput.gripTriggerIsRightClick is ignored while panelGrab is unit.

Grab-ready screens. The screens never change size or shape for it: their picture is the desktop the cursor is aimed at, and growing it moved the picture out from under the cursor (see the cursor and the dot). A screen the grip would take shows it instead with no geometry changing at all: the laser’s dot wears a ring in the accent around it (laser.grabRingScale, laser.grabRingWidth; the same dot surface the armed right click redraws, grown to hold the ring so the dot inside keeps its size), and the hand taps, both when the laser comes onto a screen and when a hand comes within grabRange of one. On the grab bar alone the bar under the laser also lights as hovered, the others glowing as siblings; on the whole unit there is no bar to light.

Two hands. While one hand holds a room panel, the other hand’s grab (its grip in range or on the face, or its laser on a bar) is no longer refused. With interaction.twoHandGrab on handOff, the default, the second hand takes the panel over at its current pose, with no jump, and the first hand lets go; both hands tap (grab on the new, release on the old). On scaleRotate both hold it (XrTwoHandGrab in Halcyon.Xr): the distance between the controllers scales it, clamped to twoHandMinScale and twoHandMaxScale of its default size, and their midpoint and the line between them move and turn it, so it scales about the point between the hands. Letting go with one hand leaves it to the other exactly where it is, at the size it has, and letting go with the last saves the size with the place: keyboard.scale, settingsPanel.scale, or for the screen wall screens.scale, which grows the radius, every screen and every gap together. The hand widget is held only by the hand that does not wear it, so there is nothing to hand over there; letting go returns it to its own hand.

Grab bars. The room panels have grab bars only when panelGrab is bar, where they are the only way to take hold, by the trigger or the grip. On unit they are gone entirely: not drawn, not made (the screens’ bar surfaces are removed when the setting changes) and never a target, since the whole face takes hold. The panels’ images keep the bars’ room either way, so switching the mode moves nothing.

Haptics. The hand that owns a pointer feels it: a tap when it comes onto a button, the same tap when it moves straight onto another, a softer and much shorter tap when it comes off onto nothing (haptics.leaveTap switches that off; it waits haptics.leaveDeadSeconds first, so crossing a channel between two cells is one switch tap rather than a leave and a hover), a tap when a press lands, and the strongest tap on a click. The taps come from what each pointer event did to the interface, not from the poke, so the laser gets exactly the same ones on the hand that points it. SteamVR’s own laser, on the settings pane, answers with its own haptics and is not tapped twice. The pulses go through one vibration action per hand in the SteamVR Input manifest (/actions/buttons/out/leftHaptic and rightHaptic).

While SteamVR owns the controllers. With the dashboard open (clicking Settings opens it), SteamVR keeps the app’s action set inactive, so a grab or a trigger could never land. The app stands down instead of pretending: no grabbable treatment or grab scale-up, no fingertip hover or press, and no laser of its own. A grab in progress is canceled unsaved, so the unit goes back to where it was last left; a fingertip or laser press in progress lets go without a click; and every held-back leave tap is dropped, so nothing taps late. When the dashboard closes, interaction resumes on the next frame with fresh state, and a button still held from the dashboard (a trigger or grip squeezed there) is ignored until it is let go, so it never presses or grabs on the way back. The log says Controller input went to SteamVR (dashboard open); interaction paused. and Controller input back; interaction resumed., and Settings still turns on and off with the pane. SteamVR’s own laser on the settings pane is untouched.

Fading behind the dashboard. While the dashboard is open every panel except the hand widget (the keyboard, the screens and their backs; any window later) fades from its opacity to dashboardFade.faintOpacity over fadeOutSeconds, and back over fadeInSeconds when it closes, eased with a smoothstep and independent of the frame rate. The hand widget, its back and the laser say FadesBehindDashboard: false on their surface, so nothing is keyed on a name. The fade is the surface’s own opacity (IXrPlacedSurface.SetOpacity, OpenVR’s SetOverlayAlpha), so nothing is drawn again, and it multiplies whatever opacity the surface was given rather than replacing it. The fade is one level from 0 to 1 shared by every fading panel, which a later dithered mode can read instead of blending. The settings pane shows the same four rows under Dashboard fade.

Every display switched on in the monitor bar becomes a screen in the room, and the screens on form one wall arranged exactly as the operating system arranges the displays (DisplayCatalog’s bounds): a monitor to the left with a vertical offset in the display settings sits to the left with the same offset. Each screen shows its display live through display capture. Where capture is unsupported, or a capture reports Unsupported, DisplayGone or Failed, the screen falls back to a placeholder drawn with Halcyon at the display’s own aspect: its number and name, the capture’s status in place of the resolution line (the resolution and Primary when there is no status), and a faint grid. --capture always shows placeholders; it never captures a real display.

The cylinder. Each display’s horizontal extent becomes an arc on a vertical cylinder around the head, screens.pixelMillimeters per desktop pixel, and its vertical extent a height. Every screen is curved to that same cylinder, so two screens that touch on the desktop meet along it with exactly screens.gapMeters of straight-line gap between their edges, angled round toward the head; at curvature 0 the cylinder is a flat wall and the screens tile in one plane. The geometry is XrScreenWall and XrCylinder in Halcyon.Xr, pure and tested, and each screen is curved through the seam’s IXrPlacedSurface.Curve(radius), which OpenVR answers with SetOverlayCurvature(width / (2πr)) (Valve’s own mapping: 1 closes the overlay into a full cylinder) and keeps across a resize.

Where it opens. The first screen turned on opens centered in front of the head as it stands level, radiusMeters ahead and heightMeters above eye height, facing back at the eyes. The wall’s origin is the middle of the screens on, so another screen turned on joins where the desktop puts it and the wall stays centered on the same point: the screens already shown slide along the cylinder to make room. Recenter puts the wall’s middle back in front of the head, level, keeping its shape.

Recenter on open. Almost every time interactive mode opened the wall was somewhere out of sight, so the switch from compact to interactive checks, before any screen resumes or shows, whether any screen on is in view from the head’s pose then. Each screen’s own curved surface (ShownScreen.Geometry, the geometry the laser hits) is sampled on a 5 by 3 grid with XrCurvedSurfaceView, and a screen is seen when screens.visibleFraction of its samples are in front of the head, within screens.viewConeDegrees of where it looks, within screens.viewMaxMeters and facing the head (the direction to the head within screens.facingDegrees of the surface’s normal at that point, the concave side of the cylinder being the front), so a wall seen from behind or edge-on counts for nothing; one screen seen is enough, so a wall whose leftmost screen alone is ahead stays. With none seen the wall is recentered by the same path as the Recenter cell, instantly, so the screens appear already in front. screens.recenterOnOpen can turn the check off or recenter every time. One [screens] line says what was decided: which screen was in view and how much of it, or why the wall was recentered. The head untracked leaves the wall where it was.

Closing gaps. Only the screens on are laid out. A stretch of desktop that only a display that is off covered is taken out along the row’s axis (ScreenGaps), so with three displays and the middle one off, the outer two touch, a screens.gapMeters apart like any two that touch. Vertical offsets from the OS arrangement are kept, and so is every gap the desktop itself has; a display stacked in the columns of another keeps them, so a stack or an L collapses along the row only where nothing else lies between. Turning a display back on opens its slot again. The moves take screens.layoutSeconds on a cubic ease-out and run along the cylinder (by arc, so each screen stays on it), from where each screen is when the change happens, so a second toggle mid-slide carries on smoothly. The slide reads the time spent and nothing else, so it comes out the same at any frame rate. A new screen’s slot opens at once while the others move, and it shows only once its first picture exists. The wall’s origin never moves for any of this, so the saved placement does not drift.

Interactive mode only. The screens and the bar show in interactive mode alone; in compact mode nothing of the wall is visible, whatever the monitor bar has on. The ones on are remembered (they are still saved and restored), and going back to interactive mode shows the same screens: each one’s capture is stopped while hidden, restarted on the way back, and the screen is sized, placed and shown again from the first fresh picture, so it never returns blank. The change is instant, with no fade. The keyboard is not tied to the mode: it shows while it is open, compact or not.

The grab bars. Every screen on has a grab bar of its own, the shared GrabBar on a small flat surface, centered under that screen a gap below it and following it through a slide; they exist only while the panel grab is the grab bar alone (grab bars), show in interactive mode only, and follow the compact-mode hide like the screens. Taking any one of them carries the whole wall as one rigid unit, rotation and the stick’s depth push and pull included. They are the keyboard’s room grab (RoomGrab, GrabAnchor.Room) with a part picked per hand: the bar its laser is on, else the one nearest its fingertip. Either hand’s grip near one, or either laser on its band with the trigger or grip, carries the wall (latched to the holding controller for the length of the hold and placed absolutely again, at the same pose, on release), with the same taps and the same Pointing gate, so nothing answers while SteamVR’s dashboard owns the controllers and a hold in progress is dropped unsaved. The bar in reach, and then the one holding, takes the grabbable and held treatment; the others glow faintly (screens.barSiblingGlow) to show they move together. A laser carrying the wall points at the bar it holds alone, so it never clicks a screen. A bar knows its screen, so a later undock can move a screen on its own bar.

Using the desktop. In interactive mode both lasers reach the screens (desktopInput.enabled), and the desktop’s one real cursor goes where they point. The laser’s own dot marks the spot, lying flat on the curve, since the captured cursor arrives a frame or two late; the capture’s cursor setting is unchanged.

  • Pointing moves the cursor to the pixel under the dot (desktopInput.hoverMovesCursor), and only when that pixel changes, so a laser held still never fights the desk’s own mouse.
  • The trigger is the left button. The press lands where it came down and stays there while the laser wanders less than desktopInput.dragSlopDegrees, so a click or a double click never drifts; past that the press follows the laser as a drag, which moves a window or selects text, and the release lets go where the laser is. A trigger already held when the laser arrives never presses.
  • The right click has three ways, the way XSOverlay and SteamVR’s own desktop do it. Arm by touch (desktopInput.armRightClickByTouch, on): while the pointing hand’s thumb rests, with no press, on the upper part of the trackpad (the touch above desktopInput.armZoneMinY, or anywhere with armZone set to wholePad) or on the upper face button (B or Y, on any controller that can feel a touch there), the trigger is a right click instead of a left. The choice latches when the trigger comes down, so lifting the thumb mid-press changes nothing. A thumb that moves past the drag slop (the scroll drag slop, in pad units) is a swipe: it scrolls and does not arm until it lifts. Click the trackpad (trackpadClickIsRightClick, on): pressing the pad in while pointing at a screen is a right press by itself, down on the press and up on the release, so a right drag works, and the wheel stays quiet while the pad is down. stickClickIsRightClick does the same for the thumbstick click and is off, and gripTriggerIsRightClick makes a pull with the grip held a right click and is off. While a right click is armed and the laser is on a screen, the laser’s dot turns armedDotColor with a small second dot at its center, and goes back the moment the thumb lifts. There is no long press: a still trigger stays a left press, and long press belongs to the touch-injection mode when it comes.
  • The wheel is the stick or the trackpad of the hand pointing at a screen, through the same analog routing as the lists: the cursor moves under that hand’s laser, then the wheel turns at the lists’ own stick speed and acceleration along the stick’s stronger axis, or by a swipe’s distance, in fractions of a notch that Windows accepts as they are. A stick pushed up scrolls toward the top and a swipe drags the content, exactly as in the lists, and scroll.reverseStick and scroll.reverseTrackpad flip them for both. A hand carrying something keeps its stick for grab depth, and every other pointer still scrolls the lists.
  • One owner. The hand pulling its trigger on a screen takes the cursor; otherwise the hand that last pointed at one keeps it.
  • Letting go. Everything held is released when the laser leaves every screen while pressing, the controller is lost, compact mode comes on, SteamVR takes the controllers, the displays change, desktop input is switched off, the app closes, or the process crashes (a backstop on unhandled exceptions and process exit).
  • Run as administrator. Windows drops input aimed at a window whose process runs elevated, such as Task Manager. A press or the wheel over one is refused without sending. The log says so once, as a [desktop] warning, and the settings’ Desktop input section shows it in red on its Status row until something goes through again. The app never asks for elevation; run it as administrator to reach those windows.

The chain from the laser to the desktop lives in the app. Each screen has one geometry, ShownScreen.Geometry: its center’s pose in the room, its width along the arc and its image’s size, the height following from the two. The surface is sized and curved from it (Resize with that width and height, Curve with the wall’s radius), the laser meets it as ShownScreen.Target through XrCurvedSurface.Raycast (Halcyon.Xr), which bends the surface onto the same cylinder XrCylinder lays it out on and OpenVR curves it to, and the dot lies on the face XrCurvedSurface.FaceAt gives at the hit. The hit’s image pixel over the image’s size is the overlay’s UV, which the capture shares; the UV times the display’s physical size is the display’s pixel, clamped to its last. DesktopPointer runs the press machine, and DesktopWheelSink sits in front of the lists’ PanelScrollSink. The pixel goes to Halcyon.Desktop’s IPointerInjector as a display id and that display’s pixel, and the injector does the operating system’s own conversion (see mouse injection on Windows). Only the live run is handed PointerInjector.Create; tests and --capture get none, and the tests record through a fake. The injector is made the first time a laser points at a screen.

The cursor and the dot. The real cursor used to land beside the laser’s dot, to the left on the left of a screen and to the right on the right, growing toward the edges. The cause was the grab scale-up: with panelGrab on unit a laser on a screen made the wall grabbable, and every screen’s surface grew 2% about its own center while the hit test, the dot and the cursor kept the ungrown geometry. The dot still sat where the ray met the cylinder (the grown surface is on the same cylinder), but the picture under it had moved outward by 2% of the distance from the screen’s center, so the cursor, drawn into that picture at the hit’s pixel, appeared that much further out: 31 desktop pixels at 95% across a 3440-pixel ultrawide, 34 at its edge (13.8 mm at 0.4 mm a pixel, about half a degree at 1.5 m), and 14 at the top and bottom of 1440. The screens no longer grow, and ScreenAlignmentTests puts each desktop pixel in the world from nothing but what the surface was told (its size, OpenVR’s curvature fraction and its placement, the texture stretched along the arc) and checks that the hit, the dot and the cursor land within a pixel of it at the center, the edges and the corners, at curvature 1 and 0.5 and at wall scales 0.6, 1 and 1.4; with the old scale-up put back, every edge case fails and the centers pass. Three more links were checked and hold: the mapping is by arc length on both sides (OpenVR’s curvature fraction is width / 2πr, so the width is the arc), the virtual-desktop normalization aims at each pixel’s center over the whole desktop with negative origins, and a slide reads the same placement the surfaces were last given. One more was fixed: OpenVR draws a texture at its own aspect, and a capture’s texture is the display’s size while the surface was made at its placeholder’s rounded size, so a display whose size is not a multiple of 8 was drawn a fraction of a percent taller than the geometry said; the OpenVR surface now takes its texel aspect from the texture it is handed.

Saved. Where the wall’s origin stands relative to the level head and which displays are on are saved on every change (screens.*), so a relaunch puts the same screens back where they were relative to wherever you face. It waits for the head to be tracked, and restores nothing unless every saved display still exists. A placement saved before the origin became the middle of the screens on reads as that point, so a wall of several screens moves once, along its row.

Displays coming and going. On DisplayCatalog.Changed an unplugged display closes its screen and the rest close up about the same origin. A rearranged desktop lays the wall out again about that origin without a slide, and a display whose resolution changed is made again.

The back. Each screen has a BackFace like the hand widget’s and the keyboard’s, with the shared watermark. The back takes the front’s size, curve radius and center from the same geometry (ShownScreen.Geometry) and is turned half a turn about the up axis to face outward. An OpenVR overlay can only curve toward its own viewer, so from behind the back bows toward the viewer as the front does toward its own; it cannot be the convex shell of the front. In a whole-unit grab the face takes hold from either side: the laser meets the front’s curve from the front and the back’s curve from behind (the nearer wins, and a ray from one side never reaches the other’s surface), and a hand’s range is its distance to the curved surface, the same from either side, rather than to the flat sheet through the screen’s center, which the curve’s edges leave behind.

What a screen shows is an IScreenContent: it says the image size its surface is made at and hands over a ScreenFrame (a Halcyon image or an external texture) whenever the picture changes, and null when the surface already holds it. The wall creates one per display that is on through a ScreenContentFactory, submits an image with Submit or a texture with SubmitExternal, and never knows where the picture comes from; PlaceholderScreen is the factory’s default. Program.RunHost passes the real factory to OverlayHost (the screenContent argument): one DisplayCaptureProvider on the headset’s adapter, and a CaptureScreen per screen that owns its capture, submits only a newer frame and falls back to the placeholder on failure. A screen turned off disposes its capture, and the provider is disposed after every screen, before Shutdown removes the surfaces and closes the device.

A new screen is shown after its first picture. A screen is created hidden and sized before it has a picture, and SteamVR did not take that geometry for the picture that came later: the panel stayed blank until another toggle sized it again. So the first picture a screen submits is followed by that screen’s size, curve and place, then it is shown, and a texture is handed over once more on the next frame, since a static desktop sends no frame of its own. The [screens] log says each screen’s first picture (first picture submitted (D3D11Texture2D) and shown), the repeat, and, once, a screen that has had no picture for two seconds.

The hand widget’s Settings button opens the settings view on a panel of the app’s own in the room, like the keyboard, instead of through SteamVR’s dashboard, and closes it again; the button is on exactly while the panel is open. settingsPanel.opensIn chooses: overlayPanel (the default) or dashboard, which is today’s behavior (the pane on SteamVR’s dashboard, on while it shows, and no call closes it). The dashboard pane stays registered either way, so SteamVR’s own dashboard still lists the app; the setting only decides what the cell opens. Changing it to dashboard while the panel is open closes the panel.

One view, three surfaces. The panel hosts the very SettingsView the dashboard pane and the desktop window show, built from the same host and model at a density for its width in the world (settingsPanel.widthMeters, 60 cm by default, so a control is the VR target size). An edit made in any of the three, or in settings.json, reaches the others on the next frame. The view has no text fields, so there is nothing for the keyboard to type into; the keyboard types into the desktop or its own echo field only.

Where it opens. Like the keyboard: in front of the head as it stands level, settingsPanel.position from it (70 cm ahead, a little below the eyes), turned to face them. It is carried by either hand by grip or laser, rigid (anywhere on its face, or by its grab bar, as the panel grab says), with grab depth on the grabbing hand’s stick or trackpad; letting go saves the placement relative to the head, so it opens where it was last left. It dims behind SteamVR’s dashboard like every other panel and has a back.

Scrolling. By laser or fingertip drag, by the thumbstick and by the trackpad, through PanelScrollSink on the panel’s own tree, with the same directions as every list.

Interactive mode only. Like the screens and the keyboard, the panel shows in interactive mode alone; compact mode hides the panel, its back and its bar and drops a hold on it unsaved. Whether it is open is remembered, so the cell stays on and the panel comes back as it was.

The hand widget’s Keyboard button opens a keyboard on a panel of its own, and closes it again; the button is on exactly while the panel is open. Its layouts, importers and key behavior are Halcyon Keyboard’s; the panel draws them and routes the pointers into them.

Where it opens. In front of the head as it stands level: turned with the head’s heading, never its pitch or roll, keyboard.position from it (by default 45 cm ahead and 30 cm down, facing back up at the eyes). From then on it is fixed in the room. On the whole unit either hand’s grip near its face, or either laser on it with the grip, carries the panel; on the grab bar alone its grab bar, the same pill as the hand widget’s in bar mode, hangs from the frame’s bottom edge, and either hand’s grip near it, or either laser on its band with the trigger or grip, carries it; with the same grabbable treatment and taps as the hand widget. Either way the panel follows the controller rigidly, so a twist of the wrist tilts it about the grabbed point (interaction.roomLaserGrabRotates off makes the laser translate only). Letting go saves where it was left relative to the level head at that moment, so it opens there again next time, wherever you stand. From behind it shows the same back as the hand widget (a BackFace, its own surface turned half a turn, with the shared watermark texture), a plate at the layout’s frame size; it is drawn again only when the layout, the image’s size or the look changes, never during a resize’s spring.

Keys. The frame is the hand widget’s button-card material. Cells are exactly a pitch apart and touch, so a pointer always lands on the nearest key; each face is inset by half the style’s channel. Flush keys (the default) have a subtle rim and 4-unit channels, inked keys a bright rim, a dark ring and 8-unit channels; the frame keeps the same 12-unit edge to the outermost faces in both, and its corners are concentric with the corner keys’. Sizes are physical: a unit is keyboard.unitMillimeters (0.375 mm, the hand widget’s density), so a Preonic key is 28 mm across. Each key shows its state: a hover lift, a pressed face that travels down keyboard.travelDistance when keyboard.travel is on, a latched modifier or layer with an accent rim and a hollow bar, a locked one with an accent fill and a solid bar, and a dim legend where the active layer falls through. Legends follow the library’s (A once Shift is latched, ! over 1); a character sits at 0.36 of the cell and a word (Shift, PgUp) at 0.19 of the unit, shrunk to fit with 6 units each side but never below 10. Legends the font atlas lacks (the arrows, ⇧, ⌫, ⏎, ◆ and ☺) are line-drawn marks, and a legend cross-fades when a layer or Shift changes it.

Interactive mode only. Like the screens, the keyboard shows in interactive mode alone. Compact mode hides the panel, its back and its bar after letting go of every key, modifier latch and hold (the injector gets the releases, so nothing stays stuck down), and nothing typed at it presses anything. Whether it is open is remembered: the Keyboard button stays on, and the panel returns as it was when interactive mode does.

Pointers. Both hands type: each hand’s laser (pointer ids 2 and 3) and each fingertip (4 and 5), each on its own. Every press goes to the library’s state machine under that pointer’s id, so modifiers latch for one-pointer typing, and a key held by one pointer is a real hold while the other types: hold Ctrl with the left laser and tap C with the right, and the system sees Ctrl down, C, Ctrl up. Dragging off a pressed key lets it go; a key held past keyboard.repeatDelaySeconds repeats. Each hand’s laser steps aside while its own fingertip is near or its grip holds the panel, and the taps are the hand widget’s. The panel reads the same Pointing gate as the hand widget: in compact mode, or while SteamVR’s dashboard owns the controllers, it answers nothing, and losing the input lets go of every key without typing it and drops a hold unsaved.

Layouts and the resize. A layout is picked in the settings pane or imported there from the clipboard. Switching springs the frame to its new size about its center on Curve.ElasticOut (Flutter’s Curves.elasticOut) over keyboard.resizeSeconds, overshooting and settling, while the old keys fade out over the first 30% and the new ones fade in from 12% to 52%, both cubic ease-out; each grid keeps its own size and the frame clips it. For the length of the spring the surface is held at the envelope of both sizes and the overshoot, so the image is allocated once when the switch starts and once when it ends, never per frame, and only the frame’s rectangle answers a pointer. The panel’s pose names the frame’s center, so the frame stays put while the surface around it changes size. A switch lets go of every key on the old layout first.

Import. Import from clipboard reads whatever the clipboard holds with the importer its shape names: a halcyon.keyboard/1 file, a QMK info.json, a VIA or Vial definition, a Chrysalis export or keymap.custom string (Kaleidoscope), or KLE raw data. A refusal shows its reason under the button and changes nothing; a QMK keymap.json alone is refused with a note to copy the info file. An import is saved as keyboards/<id>.json in the config directory, read again at every start, offered in the picker and switched to; anything it could not carry over is listed in the log.

Output. keyboard.output is inject (the default): keys go to the system’s key injector, which the panel creates only when it first opens to type into the desktop, because on a GNOME or Plasma desktop that may show the RemoteDesktop portal’s permission dialog. While the injector can reach less than it should (an elevated window on Windows, a missing Accessibility grant on macOS, no Linux route yet) its reason shows across the top of the panel. echo types only into a text field across the top, with chords shown as chips beside it (Ctrl+C), and never touches the desktop. Tests and captures are never handed the system’s injector.

The app starts in compact mode. Double-tapping the lower face button on the left controller (X on Touch, A on Index) switches to interactive mode and back, and each switch logs Mode: interactive or Mode: compact. The toggle.* keys pick another hand, button or gesture, and apply without a relaunch.

The buttons arrive through SteamVR Input. The build deploys an action manifest, openvr-input/actions.json, beside the app. It declares one boolean action per button per hand in the set /actions/buttons, with default bindings for Index (knuckles), Touch (oculus_touch), the Vive wand (vive_controller) and Windows Mixed Reality (holographic_controller). SteamVR maps the Touch defaults onto controllers that remap from Touch. On a wand or a WMR controller, the trackpad’s south and north clicks stand in for the lower and upper face buttons. The menu action is bound only on the wand and WMR, because Index and Touch have no menu button that SteamVR does not reserve. Any action can be rebound in SteamVR’s controller bindings for the app.

The grip asks for a deliberate squeeze, never fingers resting on the handle. On Index the grip source is in grab mode, whose value is the capacitive curl and the force sensor summed on 0 to 2, so value_hold_threshold 1.35 (release 1.15) is reachable only by squeezing, and force_hold_threshold 0.35 (release 0.15) asks the same of the force path; SteamVR’s own default, 0.7, is a hand merely closed around the controller. On Touch the analog grip is in button mode with click_activate_threshold 0.8 (deactivate 0.65), the same parameters SteamVR’s own compositor bindings put on an analog trigger. The wand’s and WMR’s grips are physical buttons and need nothing. A custom binding chosen in SteamVR for the app replaces these defaults.

The set is read at normal priority with no exclusive action set and no global priority, so the game still gets every button. That is also how XSOverlay behaves by default. A double tap on A reaches the game as two presses of A, so pick a button the game does not care about if it does, or toggle by motion instead.

Where every button means something to the game (in BONELAB, A plays sounds and does things), the mode can toggle with no button at all. Toggle by in the pane’s toggle binding section (toggle.by) is a set of chips: Button, Snout tap and Wrist flip, any of them at once. The last chip on cannot be switched off, and a file that names none reads as the button, so the mode always has a way back. The button’s settings below it apply while Button is on. Both motions watch the hand that wears the widget, and both stand down while SteamVR’s dashboard has the controllers and while either hand holds anything (the widget, the keyboard, the settings panel or the screens).

  • Snout tap. Hold the widget so its face looks at your eyes from in front of your face, then bring it straight in toward your face. It fires when the widget comes within toggle.snoutTap.tapDistance (13 cm) of the eyes. Below closeDistance (30 cm), a valid approach shows on the clock card as a rim that brightens and a bar along the card’s bottom edge that fills outward to the tap. It is drawn in a cool tone (ringColor), not the grab tint, so it reads as “keep going”. A small tick plays as the bar appears, and the fire flashes the card and plays a firmer tap. An approach that stops being valid fades out. It refuses a widget facing away, one held off to the side of the view, a motion coming in from the side (approachConeDegrees, maxLateralSpeed), a fast swing (maxSpeed), and a widget held close or crept in a few centimeters (minApproachTravel). After a fire, the widget must go back out past rearmDistance and cooldownSeconds must pass before it can fire again.
  • Wrist flip. With the widget facing your eyes for at least toggle.wristFlip.minFacingSeconds, roll the forearm until the widget faces away, past flipDegrees (120°), then roll it back the same way to face you again. The whole flip takes minSeconds to maxSeconds (0.3 to 1.5 s). The roll is measured about the controller’s own forearm axis (rollAxis) from where it last rested facing you. A tick plays as the roll passes the flip angle, and a firmer tap plays on the fire. It refuses a half flip, a roll the whole way round, a flip that is too slow or too fast, and an arm that moves more than maxTranslation (12 cm) during the roll.

Every fire and every near miss writes a [gesture] line with its reason (Snout tap near miss: it came in from the side (at 11.8 cm, facing 9°).). Near misses are held to one line per log.gestureDiagnosticsSeconds for each gesture. A roll that never got halfway is an arm doing something else and is not reported.

Gesture debug in the same section (toggle.debug.enabled) opens a panel beside the head and markers in the world, in both modes.

  • The panel. Each gesture shows its state machine (Idle, Armed, Approaching, Rolling, FlippedOut, Fired, Cooldown) and how long it has held that state. Every check is a bar with its threshold marked in white, green while it passes and red while it fails. A rolling graph shows the last toggle.debug.historySeconds of the widget’s distance from the eyes and of the roll, with the thresholds drawn across it and each fire (green) and near miss (red) marked. The last line names the last fire or near miss.
  • The markers. A ring floats coneRingMeters in front of the widget, sized to the facing tolerance there: while you see the widget’s center through it, the widget faces your eyes. A second ring sits on the tap distance’s sphere round the head. The laser’s line and dot run from the widget to the point where it would fire. The line is red while the widget is not posed, amber while it is posed but not approaching, and the approach’s color while the approach is valid.

To tune in the headset:

  1. Switch on Gesture debug and pick the motion to tune in Toggle by.
  2. Hold the widget the way you naturally look at it. In the panel, Facing and In front should be green; if not, widen facingMaxDegrees or inFrontMaxDegrees in the Snout tap or Wrist flip section.
  3. Do the motion slowly, then normally. Watch which bar turns red when it fails, and read the near-miss line. Move that threshold, and only that one, toward what the graph shows your motion doing.
  4. Then try to set it off by accident: look at the widget and lower the arm, reach past your face, swing your arm, rest the widget near your chin, turn your wrist to look at the time. Every one of these should stay unfired, with at most a near-miss line. If one fires, tighten the check its line names.
  5. Switch Gesture debug off. The thresholds are saved with every slider move.
Apps/HalcyonXrOverlay
-> Halcyon the cards (widgets, layout, draw lists) and Studio Black
-> Halcyon.Fonts the font atlas the cards measure and draw with
-> Halcyon.Vulkan HalcyonImageRenderer: one draw list into one image
-> Halcyon.Graphics.Vulkan the headless device, OffscreenTarget
-> Halcyon.Imaging PngWriter, the capture encoder
-> Halcyon.Xr anchors, XrAnchorMath and the overlay seam
-> Halcyon.Xr.OpenVr OpenVrOverlayBackend (SteamVR)
-> Halcyon.Desktop the displays the monitor bar lists and the screens show, and the key injector
-> Halcyon.Keyboard the keyboard panel's layouts, importers and key behavior
-> ppy.SDL3-CS the tray and the clipboard

The overlay seam. The app talks to SteamVR through IXrOverlayBackend in Halcyon.Xr: poses, pointer events and controller buttons (PollInput, once a frame) come in, and images and placements go out. The cards, the anchors and the app’s loop never see an OpenVR type, so the OpenXR overlay backend planned for Monado is a second implementation of the seam. The seam speaks raw Vulkan handles, which is why Halcyon.Xr.OpenVr references Halcyon.Xr and nothing else.

Predicted poses, latched holds. Every pose is read with GetDeviceToAbsoluteTrackingPose predicted to the photons of the frame being composed: XrPrediction.SecondsToPhotons takes the display frequency, the time since the last vsync and Prop_SecondsFromVsyncToPhotons_Float, the way SteamVR’s own samples do, so the beam, a grab and the fingertip are not a frame and a latency behind a moving hand. A thing a hand carries (the keyboard, the screen wall) is placed latched to that hand while held (XrCarry.Place: its pose in the hand’s frame, taken from the same read), so SteamVR keeps it on the controller at scan-out whatever the app’s own timing, and XrCarry.Resolve is the exact inverse, so letting go leaves it where it was. The backend does not hand the runtime a placement it already holds (XrPlacements.Same). The hand widget and its back are latched to their own hand, and to the other hand while it holds them; the log says so on each change (Hand widget placed Latched to the LeftHand, … to the RightHand). The display’s refresh rate the prediction uses is read again when the headset’s properties change or another scene application starts, since SteamVR can run each application at a rate of its own.

One tracking origin. Every pose read and every absolute placement uses the standing universe (OpenVrOverlayBackend.TrackingOrigin), through one class, OpenVrTracking, which is the only code that calls GetDeviceToAbsoluteTrackingPose, SetOverlayTransformAbsolute and GetOverlayTransformAbsolute. A device-relative placement names no universe. Reading in one universe and placing in another puts an overlay a constant transform away from where its pointer thinks it is, and which transform depends on the scene application: a game may switch the compositor to the seated universe (IVRCompositor::GetTrackingSpace) or recenter the seated zero, and neither moves the standing universe. XSOverlay reads and places in whatever the compositor reports (raw mapped to standing) and recenters its windows when that changes; this app keeps to standing, which no game moves. The backend also reads every device’s pose in one call, once per WaitFrame, so a frame’s poses are all from the same instant: a grab composed from the two hands never mixes one hand’s newer pose with the other’s older one. The tests stand a modeled room in for SteamVR (TrackingUniverseTests): seated, recentered, a hand read and a surface placed on it still lands on the hand.

The [pose] lines prove it on a live run. Tracking: compositor tracking space …; poses read in Standing; absolute placements in Standing (the runtime holds them in …); seated zero at (…) yaw …; raw zero at (…) yaw …; display … Hz is written at start and again whenever any of it changes, such as a game starting. Each controller writes Left controller: device N at (…); pose action at (…); D mm apart. when its state changes, and with log.poseSamples on, at the sample cadence while it matters: the raw device pose every placement uses beside a SteamVR Input pose action bound to the same controller’s /pose/raw (/actions/buttons/in/leftPose and rightPose, read with GetPoseActionDataForNextFrame in standing). The two agreeing within millimeters while a hand is drawn far from the controller rules out the pose read; a seated zero or raw zero that moves when the game starts names the game’s recentering or a playspace mover.

Anchors. An anchor carries a tracked source’s position, or its position and rotation. Anything attached to an anchor either follows the anchor’s rotation or only uses the anchor for calculations. The hand widget hangs from the anchor of the hand wrist.hand names, leftHand or rightHand: that controller role’s position and rotation. The widget follows it rigidly, so XrAnchorMath.Place latches it to the controller, and SteamVR applies the controller’s pose at scan-out with no lag from the app. An attachment that ignores rotation, or hangs from a position-only anchor, is placed absolutely every frame from the source’s pose instead.

Rendering. The Vulkan device is headless and enables exactly the instance and device extensions SteamVR’s compositor reports; the device extensions arrive through GraphicsDeviceOptions.PhysicalDeviceExtensions, since OpenVR reports them per physical device. Each surface has its own UiTree and its own OffscreenTarget. The target is an sRGB image with TRANSFER_SRC | SAMPLED usage, left in TRANSFER_SRC_OPTIMAL, which is how SteamVR wants a Vulkan overlay texture. Its color is premultiplied by alpha, and the overlay is flagged that way. Text is grayscale-antialiased, since the compositor resamples the card in 3D.

Shutdown order. SteamVR’s client copies each submitted overlay texture through objects it makes on the app’s own device (shared images and their memory, a command buffer, fences, a timeline semaphore) and frees them only when the connection closes. Shutdown therefore removes the surfaces, waits the device idle, closes the SteamVR connection, releases the panels’ targets and renderer, and destroys the device last. The other way round, SteamVR frees its memory on a device that no longer exists and the process fails fast (0xC0000409). --capture runs through the same order, and ShutdownTests holds it on a validated device: a whole session leaves the validation layer silent and no device memory behind, and a runtime that frees an allocation of its own on close is still given a live device to free it on.

Draw on change. A surface is rebuilt only when its model changes (the clock’s displayed text, the accent), and it is drawn only when something arrived for it or an animation moved. A new draw list that paints the same picture as the last one is not handed over. The loop waits on IVROverlay::WaitFrameSync with a timeout rather than spinning.

The settings view is built once, against a shared SettingsModel, from a list of sections whose rows are data (a choice, a switch, a slider) or a small builder. It knows nothing of the surface it is drawn on, so a floating panel can host the same view later. Its controls are Halcyon’s own, dressed by StudioBlackControls, the same dressing the engine’s options screen uses. A view edit goes through the model, which saves it and tells every host; a file edit goes through the model without being written back.

Density. Each surface takes a Halcyon UiDensity.Vr from its real scale: its width in meters over its logical width, with interaction.targetMeters and interaction.targetGapMeters as the physical sizes. The hand widget, 0.12 m across 320 units, comes out at about 56-unit targets and 8-unit gaps. Its geometry is a WristCardLayout: cells at the larger of their authored 64 units and the density’s target, the button card around them, and the bar’s room in bar mode. Fitting is simply the design now, so the old fitButtonsToTargets switch is gone. A layout of a different size resizes the panel’s image and remakes its surface. The settings pane, 2 m across 640 units, comes out at about 7-unit targets, below every control it has, so it draws exactly as before. A far panel wants UiDensity.VrAngular instead, which sizes targets by the angle they subtend; the pane does not use it yet.

Input. SteamVR’s laser mouse arrives on each overlay’s own queue, in mouse-scale units from the bottom-left. The backend turns those events into seam pointer events in image pixels from the top-left, and the app turns them into Halcyon PointerStates. Every seam pointer event carries a pointer id: SteamVR’s laser is 0, the fingertip is 1, and the app’s lasers are 2 (left) and 3 (right), and each id keeps its own buttons on a panel. The fingertip is one of the app’s own pointers: XrSurfaceGeometry puts it on the card’s pixels with its depth, XrPokeTracker decides hover and press, and XrPointerStream turns the result into the same seam pointer events the laser produces. The laser swaps the first two for a ray hit: XrRay.From builds the ray from the controller pose and the aim, XrLaser.Raycast meets a surface’s front face, and XrLaserTracker turns the trigger into the button; XrPointerStream and everything after are shared. After each pointer event a panel reports what it did to the interface (UiTree.HoveredInteractable before and after, whether a press landed, whether a click fired), and XrPointerHaptics maps that to the one tap it earns. XrLeaveDeadTime holds each pointer’s leave for haptics.leaveDeadSeconds and turns a leave followed by an enter into a switch. All of these are pure and tested in Halcyon.Xr, as are XrProximity (the grab range’s hysteresis), XrGrab (holding a pose by a grip and answering its new offset on the anchor it stays attached to) and XrRayGrab (the laser’s grab, carrying the held point at a fixed distance along the ray).

Input focus. IXrOverlayBackend.InputAvailable says whether the controllers reach the app, and XrInputFocus in Halcyon.Xr is the one place the app reads it: each frame it takes that flag and the polled buttons, reports Lost or Regained once, reads every button as released while the input is away, and masks a button held across the return until it is let go. The host derives one Pointing gate (interactive mode and input available) that the fingertip, the grab and the laser all read, mutes every haptic while the input is away, and cancels rather than releases a hold, so any new interactive surface, the keyboard panel included, gets all of it by reading the same gate. The OpenVR backend answers !IsDashboardVisible, the check Valve gives for the dashboard being active. VREvent_InputFocusCaptured and Released are deprecated since SDK 1.0.12, whose release notes replaced them with IVRSystem::IsInputAvailable; that call is documented for the scene application and is not yet confirmed for an overlay process, where a false that never cleared would freeze the app, and an action’s bActive is also false for an unbound action or an absent controller. So the live loop logs all three (SteamVR input signals: IsDashboardVisible …, IsInputAvailable …, actions active …) on every change, and a headset run decides whether the gate should take in the others.

The laser’s surfaces. The beam and the dot are overlays of their own, drawn once per look (a trapezoid for the beam, a ringed disc for the dot) and only placed and sized every frame after. The beam is placed absolutely, centered on the ray with its long axis along it, and turned about that axis toward the head (XrLaser.Beam), so a beam a few millimeters wide never shows its edge. The dot lies on the panel at the hit. Both use the one tracking origin. The seam gained IXrPlacedSurface.SetOpacity, XrOverlaySurfaceInfo.FadesBehindDashboard, IXrPlacedSurface.Resize, a size independent of the image’s aspect, and XrOverlaySurfaceInfo.SortOrder, so the beam (1000) and the dot (1001) draw over the card (0) whatever their distance. OpenVR sizes an overlay by its width alone, so the backend reaches the beam’s length through the texel aspect (SetOverlayTexelAspect); an OpenXR quad layer takes both sizes directly.

The back. An overlay made with OpenVR SDK 2.15 or later gets a translucent gray back from SteamVR by default, whatever its image; that was the plain rectangle the hand widget showed from behind. SteamVR offers nothing more for it than one color for every app, which the person sets (overlayBacksideColor), and a flag, VROverlayFlags_NoBackside, that draws only the front. XSOverlay builds against an older SDK and does nothing of its own here either. So the hand widget’s surface now draws its front alone (XrOverlaySurfaceInfo.RuntimeBackside = false), and a second surface, the back (BackFace), sits at the same anchor and center turned half a turn about the unit’s up axis, also drawing its front alone. From any side exactly one of the two shows, so they never fight over depth. The back’s image is the front’s size: transparent but for a plate behind what the unit covers in its current mode (the clock card, plus the button card or the monitor bar when out), in the button card’s material at look.backPlateOpacity, with the Molten Labs sign centered on it in white. The image runs right to left from behind, so the plate is drawn at the covered area’s mirror. The sign ships as mltn authored it (1800 × 500, white on transparent) and is decoded and scaled down to 512 pixels wide once per process, then uploaded once as one texture every back samples; its color comes only from the draw’s tint. The back is drawn and handed over once, and again only when the covered area, the look or the image’s size changes: a mode toggle, the monitor bar, an accent or a look.* edit. It is placed by the same call that places the front, and resized by the same call that grows it while grabbable, so a latched back costs SteamVR’s compositing and nothing per frame. The dashboard pane keeps SteamVR’s own back: OpenVR ignores the flag on a dashboard overlay and places the pane itself. Halcyon blends in linear light, so a 5% white over the dark plate reads about as strong as 20% would in a gamma-space editor; the setting carries the honest number, and about 0.01 matches a gamma-space 5%.

Animation. The button card’s slide and the grabbable tint are Halcyon’s Animated, an implicit animation that hands its eased progress to a builder, over Reveal, a clipped drawer that shows a fraction of its child’s height from the bottom edge up. The loop draws every frame while either runs and goes idle again when both settle.

Gestures. The buttons are named by role in Halcyon.Xr (XrControllerButton, XrHand), and XrTapRecognizer there turns one button’s (time, pressed) samples into a tap or a double tap. It is pure, so the engine can reuse it. Which hand, button and gesture toggle the mode is the app’s policy (ModeToggleBinding).

Motion toggles. XrGestures in Halcyon.Xr holds the two button-free gestures, XrSnoutTap and XrWristFlip. Each is a pure state machine over XrGestureSamples (time, head, controller, widget, enabled), takes its thresholds (XrSnoutTapOptions, XrWristFlipOptions) on every sample, and reports every check, its state and how long it has held it, and the reason a near miss did not fire, so a debug view needs no logic of its own. XrGestureMath holds the shared geometry: facing and in-front angles and the twist of a rotation about an axis. The snout tap smooths the widget’s velocity over velocitySmoothingSeconds. The wrist flip unwraps the roll one sample at a time, so a turn the whole way round reads 360°, and measures it from where the forearm last rested facing the eyes. The app’s policy is MotionToggle: which gestures listen, standing them down, the haptics, the widget’s feedback (GestureRim over the top card), the [gesture] log and the debug history (GestureTrace). The debug panel is a RoomSurface, the same surface-and-back pair the settings panel is hosted by. Its world markers are the laser’s own LaserSurfaces for the line, plus a RingSurface for each ring.

Key injection on Windows. The keyboard types through Halcyon.Desktop’s IKeyInjector, and KeyInjector.Create() returns WindowsKeyInjector on Windows. Keys go through SendInput with KEYEVENTF_SCANCODE and the set-1 make code from Microsoft’s scan code table, with KEYEVENTF_EXTENDEDKEY for E0 keys (arrows, the navigation cluster, right Ctrl and Alt, the Windows and menu keys, Print Screen, keypad divide and Enter, and Num Lock, which shares 0x45 with Pause). DirectInput and raw-input games ignore a key that carries only a virtual-key code, and the active layout derives the virtual key from the scancode exactly as it does for hardware. Media and volume keys have no keyboard scancode, so they go by virtual-key code. Text goes through KEYEVENTF_UNICODE, a down and an up for each UTF-16 unit, so an emoji is its surrogate pair, and any line break becomes one carriage return. Each request is one SendInput call, which Windows plays without interleaving other input; there is no length limit to chunk around. Windows drops input aimed at a window running at a higher integrity level, typically one run as administrator. The injector reads the focused window’s process integrity first and refuses without sending (Capabilities is then None), and also treats a short count from SendInput as a refusal; either way Status says the focused window is elevated. Calls may come from any thread. Games and anti-cheat that discard injected input are out of reach of any user-mode path.

Mouse injection on Windows. The screens use the mouse through Halcyon.Desktop’s IPointerInjector, which sits beside IKeyInjector with the same shape: Capabilities, Status, a Refusal flag, Move, Down and Up per button (left, right, middle, back, forward), Scroll and CancelAll. A point is a DesktopPoint, a display id and that display’s physical pixels; the injector resolves it against the display catalog’s bounds, reading them again only after Changed. PointerInjector.Create(displays) returns WindowsPointerInjector on Windows. It sends SendInput with MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK | MOUSEEVENTF_MOVE over the whole virtual desktop, aimed at the pixel’s center, so Windows’ own truncation lands on the same pixel however wide the desk is (negative origins included). A press is the move and the button in one call, and the wheel is MOUSEEVENTF_WHEEL and MOUSEEVENTF_HWHEEL in fractions of WHEEL_DELTA, with what one step cannot hold carried to the next. Every send, and the read of the virtual desktop’s bounds, runs with the thread per-monitor DPI aware (v2) through PerMonitorDpiScope, which the display catalog shares, so a scaled monitor is never off target. Before a press or the wheel, the injector reads the integrity of the process that owns the window under the point (WindowFromPoint, then the same WindowReach check the key injector makes of the focused window). Over an elevated window it refuses without sending, sets Refusal to Elevated and says so in Status. A move is always sent. Touch and pen contacts are reserved in the interface (PointerMode, ContactDown/Move/Up) and answer Unsupported for now. Linux and macOS get an UnsupportedPointerInjector whose Status names the routes planned: EIS, the portal, XTest and uinput on Linux, and Quartz events on macOS.

Key injection on macOS. KeyInjector.Create() picks MacKeyInjector, which posts Quartz keyboard events to the HID event tap. Keys go by kVK_ virtual keycode, mapped from each HidUsage by position, and carry the flags of the modifiers the injector holds, plus the Caps Lock bit the system holds. Media keys go as AppKit’s NSSystemDefined auxiliary-key events. Text goes through CGEventKeyboardSetUnicodeString in chunks of at most 20 UTF-16 units, the limit past which macOS drops the rest silently, and a chunk never splits a surrogate pair. Any line break becomes one press of Return, without the held modifiers. Some apps translate the keycode and ignore the string. Posting needs the Accessibility grant. The injector reads it with AXIsProcessTrusted on every call, which never prompts. While the grant is missing, Capabilities is None and Status says where to turn it on. MacAccessibility.RequestTrust() is the one call that shows the system prompt. Make it only from an explicit user action taken before the headset goes on, since the prompt is a desktop window. The grant follows the signed binary, so an unsigned development build may need it again, and a run from a terminal needs it on the terminal. macOS has no Print Screen, Scroll Lock, Pause, F21 to F24 or media Stop, and those report Unmapped.

Key injection on Linux. KeyInjector.Create() picks LinuxKeyInjector, which tries a list of routes on a background thread and sends through the first that opens. Until one does, calls return Refused and Status says what it is waiting for; when none opens they return Unsupported and Status gives each route’s reason. Every route takes evdev key codes, mapped from each HidUsage exactly as the kernel’s own HID table maps them, so the ISO IntlHash key reports as KEY_BACKSLASH the way real hardware does. The routes, in order:

  • gamescope’s EIS socket, through libei (libei.so.1, opened at run time). SteamOS Game Mode and the Steam Frame run gamescope, which listens on $XDG_RUNTIME_DIR/gamescope-0-ei (or LIBEI_SOCKET) and asks for no consent.
  • The RemoteDesktop portal on a Wayland desktop, version 2 or later (GNOME 46+, Plasma 6.1+). Its ConnectToEIS hands libei a socket. The first start shows the desktop’s permission dialog, so ask for it before the headset goes on. The restore token it returns is saved to $XDG_STATE_HOME/halcyon/remote-desktop-token (by default under ~/.local/state), so later starts skip the dialog. A denial is reported in Status and the search moves on. The portal is reached over D-Bus with Tmds.DBus.Protocol, the managed client Avalonia also uses.
  • XTest on a pure X11 session (DISPLAY without WAYLAND_DISPLAY). It is the one route with arbitrary text: a character the layout lacks is bound to a spare keycode for its press, as xdotool does, and then unbound.
  • /dev/uinput, a kernel virtual keyboard every compositor treats as hardware. It needs write access, which Steam’s 60-steam-input.rules grants to the logged-in user. Without it, Status says to install Steam’s udev rules or join the input group.

Text on the libei and uinput routes can only type what the active layout produces, so Capabilities reports TextFromKeymapOnly and a string with any other character is refused whole as Unmapped. The keymap comes from libei when the compositor sends one, and otherwise from the X server (Xwayland under gamescope), read again on each call so a layout switch is seen. A compositor that offers libei 1.6’s text capability types any Unicode instead, and reports Text. Text types each character with Shift or AltGr (right Alt) as its level needs and skips keypad keys, and any line break becomes one Return. Calls may come from any thread and are serialized; keys still held are released when the injector is disposed, and a lost connection starts the search again.

Probed on the SteamOS cube in Game Mode without sending a key: gamescope’s EIS socket opens a keyboard with Keys, TextFromKeymapOnly, its Xwayland keymap types 103 code points (US layout), /dev/uinput is writable through Steam’s rule, and there is no RemoteDesktop portal. Typing itself is unverified until mltn runs it. Deferred: zwp_virtual_keyboard_v1 on wlroots compositors (which could type any character), becoming an input method, and an opt-in clipboard fallback for characters no key produces.

Display capture. Halcyon.Desktop’s IDisplayCapture hands out a display’s newest picture as a GPU texture, with its size and a frame number. Nothing is ever read back to the CPU, and no picture is ever written to disk or the log. TryAcquireLatest answers true only for a picture newer than the last one it returned, so the app submits only when the display changed. A started capture reports its status (Waiting, Capturing, DisplayGone, Unsupported or Failed, with a sentence) and how its picture differs from the desktop (DisplayCaptureNotes). It finds its display again by id after the display goes away, after a mode change closes the capture, and after the GPU device is lost.

Windows captures through Windows.Graphics.Capture, reached through hand-written COM and WinRT vtables with no projection or package:

  • An item is made for the display’s HMONITOR (IGraphicsCaptureItemInterop::CreateForMonitor).
  • A free-threaded frame pool sits on one Direct3D 11 device that every capture shares. The device is made on the adapter SteamVR’s headset is on (IVRSystem::GetOutputDevice, XrOverlayGraphicsRequirements.AdapterLuid), so nothing crosses adapters.
  • The session asks for the pointer and for no border.

Each frame, the app’s loop drains the pool to the newest frame and copies it once on the GPU into a ring texture: BGRA8, shareable, sampled and a render target. The pool’s own buffer goes straight back to the pool. The ring hands out a texture only when no reader holds it, so the one on show and displayCapture.retainedFrames before it are never rewritten. A resolution change remakes the pool and the ring.

The surface takes the texture through IXrPlacedSurface.SubmitExternal. OpenVR shows a Direct3D texture with TextureType_DirectX, or a shared handle with TextureType_DXGISharedHandle, as gamma, so the colors match the monitor. The Vulkan device is never involved.

Per new frame, the default path costs two GPU copies and no CPU work:

  1. The app copies the frame into the ring.
  2. SteamVR copies the ring texture into its own round-robin textures on the same device.

sharedHandle drops the second copy, because the compositor reads the ring texture directly, but SteamVR filters a shared handle more softly, which shows on text in the headset. A frame reaches the compositor on the app’s next WaitFrameSync after Windows produces it, at most one compositor frame later.

Importing a shared NT handle into Vulkan (VK_KHR_external_memory_win32) was considered and set aside. It still costs the same two copies, because SteamVR copies a Vulkan overlay texture too, and adds a keyed mutex or a shared fence on top. It would only earn its place for drawing a display inside a Halcyon panel. DXGI Desktop Duplication was set aside as well: it works only on the adapter that owns the output, and it leaves the pointer to the app.

Known limits:

  • Protected content (DRM video, windows that exclude themselves from capture) shows black. Windows does not say when this happens, so every Windows capture carries ProtectedContentHidden.
  • An HDR display is captured as SDR (HdrAsSdr), so highlights clip.
  • The border request needs Windows 11. Windows 10 shows the border, and the status says so.

The ICaptureSource contract also serves a producer that picks its own buffer, as PipeWire does. Such a source reports the slot it filled, and the ring hands each buffer back once no reader holds it. The texture kinds also carry a Vulkan image and a Linux DMA-BUF (fourcc, modifier, planes, sync fd). OpenVR answers Unsupported for a DMA-BUF until ImportDmabuf is bound.

The tray is two implementations behind one ITray. On Windows it is a Win32 notification icon (Shell_NotifyIcon, TrackPopupMenu) on a thread and message pump of its own: the menu is a modal loop, and on the frame loop’s thread (where SDL’s tray ran it, inside SDL_PumpEvents) an open menu froze the lasers and the screens until the real mouse dismissed it. Elsewhere it is SDL3’s tray, where the shell draws the menu; its icon’s left click is a tray click callback (SDL_PROP_TRAY_CREATE_LEFTCLICK_CALLBACK_POINTER, which returns false so the menu does not also open), and the right click keeps SDL’s menu. Either way every choice is posted to a thread-safe queue (TrayChoices) that the frame loop drains once a frame, so nothing the menu does can stall it. Where a platform has no click callbacks, the Settings item still opens the window. Where a session has none (a headless Linux session, a desktop with no StatusNotifierItem host), the app runs without it.

The app builds, tests and renders on linux-arm64, the Steam Frame’s SteamOS with SteamVR’s native arm64 runtime and the Turnip (Adreno) Vulkan driver. Nothing in it is OS-specific beyond the runtime-identifier switches in Halcyon.Xr.OpenVr and Halcyon.Desktop.

Publish it self-contained. On the machine that will run it:

Terminal window
dotnet publish Apps/HalcyonXrOverlay/HalcyonXrOverlay.csproj -c Release -r linux-arm64 --self-contained true -o <dir>

Self-contained is the right shape for SteamOS: its root file system is read-only, so there is no system .NET, and SteamVR starts a registered app through its apphost with no DOTNET_ROOT in the environment, which a framework-dependent build would fail on. The output is about 100 MB. A framework-dependent publish (--self-contained false) also works when DOTNET_ROOT is set.

Every native dependency resolves for the RID with nothing extra to install:

  • libopenvr_api.so comes from External/Libs/OpenVR/linux-arm64 (git-LFS; run git lfs pull --include "External/Libs/OpenVR/**" before syncing a tree to the headset) and is published to runtimes/linux-arm64/native.
  • libSDL3.so ships in ppy.SDL3-CS for arm64.
  • The Vulkan loader and the Turnip driver are SteamOS’s own; the validation layer is not installed there.
  • Fonts and the noise field are embedded resources.
  • Shaders are compiled to SPIR-V at build time by the shaderc binary NuGet ships for the building machine’s RID, so the build must run where it publishes (the Frame builds its own).

Install layout. The Frame mirrors the engine’s: ~/Applications/HalcyonXrOverlay/<short-sha> with a current symlink, and the launcher ~/.local/bin/halcyon-xr-overlay, which runs current/HalcyonXrOverlay and passes its arguments through. It needs a running SteamVR and never starts one. halcyon-xr-overlay --capture <dir> writes the pictures without SteamVR, on the Frame’s GPU.

Settings and logs live under $XDG_CONFIG_HOME/HalcyonXrOverlay, or ~/.config/HalcyonXrOverlay. The manifest is written beside the executable with binary_path_linux.

Known gaps on Linux.

  • Screens show their placeholder: display capture answers Unsupported until the ScreenCast portal and PipeWire route exists.
  • The laser cannot click the desktop: the pointer injector answers Unsupported with the planned routes in its status. Keys have routes (gamescope EIS, the RemoteDesktop portal, XTest, uinput), tried in that order.
  • The tray needs a StatusNotifierItem host; without one the app runs with no tray (the log says why). Whether the Frame’s session has a host is not yet known.
  • The tests that need the Khronos validation layer skip on the Frame (ValidatedVulkanDeviceFact); every other test runs.
  • A laser for the hand that wears the widget, and other panels for the laser to point at; both are another LaserPointer or another target.
  • For the desktop’s mouse: Linux (gamescope’s EIS absolute pointer, the RemoteDesktop portal, XTest, uinput) and macOS (Quartz events) routes; a touch mode (CreateSyntheticPointerDevice with PT_TOUCH) for native drag-scrolling; the middle click; drawing the real cursor’s shape at the dot; a grace for a drag that crosses the gap between two screens (today the press lets go). Linux capture (the ScreenCast portal and PipeWire) comes after Windows. macOS has no capture, since SteamVR does not run there.
  • For the keyboard: rotated keys (from a KLE or Model 100 import) are drawn upright about their rotated centers, though they are hit-tested rotated; color emoji on the phone’s emoji page (the atlas has no emoji); legend sizes land on the font ladder’s nearest rung (a 25-unit character legend draws at 22); a press dragged off a modifier taps it rather than canceling, since the library has no public cancel for one pointer; and legends that follow the OS keyboard layout (an IKeyTextMap from the injector) rather than the US one.
  • Closing the dashboard from Settings, which OpenVR offers no call for.
  • For scrolling: the stretch is a uniform scale about the pinned edge, where Android’s shader magnifies the edge most (0.7 interpolation) and stretches glyphs too, so the same numbers read weaker here (scroll.stretchGain makes up for it); hit testing is not transformed during a stretch; and no list scrolls sideways yet, so a horizontal drag stays a press.
  • Re-anchoring beyond the choice of hand.
  • A head-angle fade for the hand widget.
  • Scaling the hand widget with both hands; it is held only by the hand that does not wear it.
  • A hand’s nearness to a curved screen, for the face grab and the reach tap, is measured to the flat plane through the screen’s center, so near the edges of a strongly curved screen a hand must come closer than grabRange.
  • Moving the gesture debug panel by hand: it opens where toggle.debug.position puts it and only shows; nothing points at or grabs it.
  • The motion toggles on the hand that does not wear the widget.
  • Blocking game input while pointing (a raised action set priority).
  • Autolaunch registration (.vrmanifest).
  • The OpenXR XR_EXTX_overlay backend for Monado and WiVRn.
  • Picking the GPU SteamVR renders on when a machine has more than one.
  • A back plate that follows the unit’s exact outline (the button card is narrower than the clock card) and the reveal’s animation; today it is one rounded plate over what the unit covers, changed when the mode changes.