22 KiB
Lambda1VR on the Steam Frame: how it fits together
Current state of the Frame build (assembleFrame, L1VR_STEAM_FRAME). The public
description is in STEAM_FRAME_VR.md; this one is for development.
First unattended device runs 2026-10-04 (headset on a desk, over adb, nobody wearing it); nothing has been worn yet.
1. Target and probe facts
| Item | Value |
|---|---|
| Device | Valve Steam Frame, Snapdragon 8 Gen 3, Adreno 750 |
| Container | Lepton: Android 11 (API 30), arm64-v8a, GLES 3.2 through Zink on Turnip |
| Runtime | SteamVR OpenXR 2.17.10 at /vendor/etc/openxr/1/active_runtime.json; no loader broker, so the Khronos loader 1.1.63 is in the APK |
| Recommended eye size | 1728x1728, asymmetric FOV (left eye about L -1.028, R 0.885, U 0.864, D -1.048 rad) |
| Refresh | the rate set for the app in SteamVR (72 Hz by default); XR_FB_display_refresh_rate offers only that |
| Controllers | /interaction_profiles/valve/frame_controller_valve (XR_VALVE_frame_controller_interaction) |
| GL | GL_OVR_multiview(2) are advertised, not used: one swapchain per eye, sRGB (GL_SRGB8_ALPHA8), plain GLES 3 framebuffers |
| Game libs | libxash.so (engine plus the VR layer), libgl4es.so (GL on top of GLES), libmenu.so, libclient*.so, libserver*.so |
| Data | /sdcard/Documents/Lambda1VR (the headset's ~/Documents/Lambda1VR) |
| Package | com.drbeef.lambda1vr.frame, minSdk 30 |
Measured 2026-10-04 (unattended): SteamVR/OpenXR 2.17.10 offers 48 instance extensions, not XR_EXT_performance_settings;
swapchain formats 0x8c41, 0x8c43 (sRGB8_ALPHA8, used), depth16/24/32F; recommended 1728x1728; the runtime listed 90 Hz but the
frame period is 13.89 ms (72 Hz); Frame, Index and Touch suggestions all XR_SUCCESS; gl4es reports "GLES 2.0 backend", Mesa.
Two things that bit: the plain gl* names in libxash.so resolve to gl4es (it's first in NEEDED), and gl4es doesn't know
framebuffers or textures the driver made. So swapchain attachments are set with eglGetProcAddress functions, but the
framebuffers are created and bound through gl4es (it refuses to bind ones it didn't make). A readback from the eye
framebuffer has to use the driver's glReadPixels too. jVM was never assigned, which made the quit path fall off the end of
AppThreadFunction (fixed).
2. Frame protocol (as found in TBXR_Common.c and L1VR_SurfaceView.c)
One thread (AppThreadFunction) owns the EGL context, the OpenXR session and the engine.
TBXR_InitialiseOpenXR: EGL context (pbuffer),xrInitializeLoaderKHR, instance (Frame: only the extensions the runtime lists), system, GL requirements, view configuration, eye size.Host_Main(engine init),VR_Init(cvars),TBXR_EnterVR(session, head-space),TBXR_InitRenderer(reference spaces, swapchains),TBXR_InitActions(actions, bindings, attach),TBXR_WaitForSessionActive.- Each XR frame:
TBXR_FrameSetup: events and session state,xrWaitFrame,xrBeginFrame,xrLocateViews(vrFOV, the per-eye projections), head pose, controllers (xrSyncActions), haptics.- Frame build: if
shouldRenderis false the frame goes in with zero layers and nothing else runs. Host_BeginFrame: the whole server frame, input, timing. Once per XR frame.- for each eye: acquire the swapchain image, set
vr_stereo_side(0/1, or 2/3 for the mono scope eyes),Host_Frame(the client frame and the render of that eye), comfort-mask and border clears, release. Host_EndFrame, thenxrEndFramewith a projection layer (both swapchains) or, whenVR_UseScreenLayer(), a 5 x 4.5 m quad layer 4.5 m away that shows eye 0 only.
- Frame build: each eye is drawn from the located eye pose in head space (
VR_GetEyeTransform, position scaled byvr_worldscale, plus the eye's rotation if any) and its projection view is submitted with the head pose placed by that eye pose (VrEye_ComposePose), both from the samexrLocateViewsat the predicted time. Mono scope eyes and the screen layer draw from the middle and submit the head pose. (Quest build: fixed 65 mm timesvr_worldscale, head pose for both views.) - Exit:
runStatus == 0leaves the loop:Host_Shutdown,TBXR_LeaveVR, Javashutdown()(System.exit). The Frame build also sets it onEXITING,LOSS_PENDINGand instance loss.
Session state is logged with [openxr] session state: X. FOCUSED and VISIBLE set flags on ovrApp that
nothing reads. Input while unfocused is empty (the runtime gives nothing); the game isn't paused on focus loss.
Spaces: CurrentSpace is STAGE when the runtime has it (else LOCAL minus 1.675 m). Recentre (Frame build,
View held 1 s) moves our offset yaw and floor position, keeps the game's view yaw (snapTurn takes the difference)
and the world position, so nothing jumps.
3. Camera-relative and screen-space draw inventory
How the engine draws what isn't plain world geometry, and the rule each one has now. From reading the code,
nothing run. Paths are under Projects/Android/jni/src (E/ = Xash3D/xash3d/engine).
Common rules: the eye is vr_stereo_side; R_SetupModelviewMatrix (E/client/gl_rmain.c) shifts by
vr_worldscale * VR_IPD / 2 (a fixed 0.065 IPD; the Frame build uses the runtime's eye poses instead); the projection is the headset's
asymmetric per-eye FOV (VR_GetVRProjection); 2D content gets R_Get2DOffset (E/client/gl_draw.c), the pixel
shift from the optical axis; GetStereoDepthOffset adds +-width/36 per eye for HUD items (two copies, E/client/cl_scrn.c
and the client DLL's hud.cpp).
| Category | Where | Rule it uses now | Notes |
|---|---|---|---|
| HUD | E/client/cl_view.c V_PostRender, cl_game.c CL_DrawHUD / CL_RedrawHUD, client DLL |
Quest: 2D ortho with the axis shift, most elements add the DLL's own width/36 per eye (about 0.4 m on the Frame's lenses). Frame build: the DLL is told the eyes are mono while it draws (no shift), and the whole HUD pass (centre print too) is shifted in R_Get2DOffset by VrEye_HudShiftPixels for vr_hud_distance (default 1 m) |
hud_scale 1.3; messages, scoreboard and VGUI draw outside that pass and stay at infinity; scope and the screen layer unchanged; the HUD redraws once per eye |
| Sprites | E/client/gl_sprite.c, gl_rpart.c, gl_beams.c |
oriented with the shared view vectors, positions from the cyclopean RI.vieworg; eye shift is in the modelview |
fine in stereo; glow sprites test occlusion from the middle eye |
| Crosshair | E/client/cl_game.c CL_DrawCrosshair |
only in a scope: centre of the 2D space (the optical axis), rotated by hand and head roll, mono | outside a scope the client DLL forces crosshair 0 |
| Laser sight | server DLL (weapons.cpp, rpg.cpp) |
a beam entity or a glow spot, both world entities | follows the weapon's aim vector, not the controller ray |
| Comfort vignette | cl_game.c CL_DrawVignette, mask in L1VR_SurfaceView.c |
textured quad and scissor mask, both centred on the optical axis | three separate level statics each step per eye; the mask isn't gated on the screen layer |
| Scope overlay | cl_game.c CL_DrawScope |
mono eyes (2/3), centred | three different "scope engaged" thresholds (vrFOV, 85, 85) |
| Menus and console | E/client/cl_view.c, cl_menu.c, mainui/ |
2D mode on eye 0, shown on the screen-layer quad; symmetric projection, no axis shift | the quad is at y = 1.0 in stage space, 4.5 m along playerYaw |
| Pointer | E/client/touch.c, VrInputCommon.c |
angle based: -sin(yaw - playerYaw), pitch / 90 from the controller angles |
not a ray against the quad; no ray is drawn |
| Skybox | E/client/gl_warp.c R_DrawSkyBox |
cube of half-size farClip / 2 around RI.cullorigin (middle eye) |
effectively at infinity; fine in stereo |
| View weapon, hands, torch | E/client/gl_studio.c, cl_view.c, client DLL vr_helper.cpp |
world positions: vieworg + weapon.org, controller offset scaled by vr_worldscale; depth range squeezed to 0.3 |
origin computed on the left eye and reused; hidden in a scope |
| Fog | E/client/gl_rmain.c, gl_rsurf.c, cl_game.c |
GL fog in eye space | the sky isn't fogged unless custom fog |
| Mirrors | E/client/gl_mirror.c |
reflect the middle-eye view, then the normal eye projection | mirror viewport clamped to 96-1024 x 72-768, so the aspect is off; copies the eye framebuffer |
| Culling | E/client/gl_rmain.c R_SetupFrustum, gl_cull.c |
symmetric frustum from vrFOV (int degrees) about the middle eye |
can be narrower than the real per-eye FOV; vertical edge pop-in possible |
| Screen fades, cinematics | cl_game.c, cl_video.c |
fades overdraw the shifted ortho; cinematics go on the screen layer | |
| Shake, strobe | client DLL view.cpp, gl_rstrobe.c |
shake compiled out under VR; strobe off |
Frame split inside the engine: Host_BeginFrame runs the server and input once per XR frame; Host_Frame runs per eye
and only does the client frame. Per-frame client state runs on the left eye (cl.time, CL_ReadPackets, CL_AddEntities,
V_SetupRefDef, pfnCalcRefdef) or the right eye (S_RenderFrame, CL_DecayLights, cinematics, console).
4. Controls
Source of the layout: Half-Life's own gamepad layout from the 25th anniversary update (valve/controller_configs/xbox_controller_config_standard.vdf),
with the VR parts added (grips, head look). It replaced the first Frame map (the Quest layout moved around) after the second wear, and replaces
standard.md sections 1 and 2 where they differ: B is crouch (not back), A jump, X use, Y reload, bumpers and D-pad pick weapons, right stick click is the
flashlight. Menu and View kept their system jobs (Menu pause, View recentre) as in the standard.
Code: VrFrameMap.c is the map as one table (rows[]: physical input, context, action); VrInputFrame.c turns it into the Quest-shaped states that
VrInputDefault.c reads (dominant hand: trigger, backpack grip, turn stick, use on the stick-click bit, crouch and jump on its two buttons; other hand: steady on
the grip, move stick, torch and screen view on its two buttons, laser on its stick-click bit) and into commands for what the scheme code doesn't have
(alt fire, reload, weapon select, last weapon, crowbar, quick save, menu keys, recentre). OpenXrInput.c reads the physical buttons (new dpad* actions) and logs the
profile. It only runs when the runtime reports the Frame profile active (or diag_press is in use). Contexts: menu (cls.key_dest != key_game), weapon hand
behind the head, other hand behind the head (not in multiplayer), multiplayer, and the other grip's mode (decided at press time).
Scheme code kept as it was: aim from the controller, crowbar swing (weapon velocity), flashlight beam from the off hand, two-handed hold and scope (vr_weapon_stabilised,
+alt1), backpack grab, physical ducking (vr_enable_crouching), room-scale walking, haptics, usable-object highlight. Server side (hlsdk-xash3d/dlls/player.cpp,
PlayerUse): +use finds usable things within PLAYER_HAND_SEARCH_RADIUS of the weapon hand's position in front of the hand's direction (64 units; 24 and a wide cone
when vr_gesture_triggered_use is 1), and +use2 does the same from the off hand but only when that cvar is 1. So hand-origin use already exists and needs no DLL change; the
grips need the cvar at 1 and the client's automatic gesture use switched off (VR_FrameHandUse(), VrInputDefault.c). The reach for +use is therefore 24 units (0.6 m), which also
applies to X.
Changed defaults on the Frame (saved settings are updated once, vr_frame_map): vr_quick_crouchjump 0 (double clicking A would be a second crouch input), vr_gesture_triggered_use 1 (see above).
Not carried: walk/slow (+speed, was the off trigger; the stick is analog), weapon select on the turn stick (it fought the turn).
Not kept from the standard: dominant-hand setting beyond vr_control_scheme, no VR settings page.
The table, generated by tools/steam_frame/tests/test_frame_map --markdown:
| Frame input | Playing | Notes |
|---|---|---|
| weapon hand trigger | fire | weapon hand trigger |
| other hand trigger | alt fire | other hand trigger |
| A | jump | |
| B | crouch (hold) | |
| X | use | |
| Y | reload | |
| left stick click | crouch (toggle) | |
| right stick click | quick save | off hand behind the head |
| right stick click | flashlight | |
| View (short) | quick save | off hand behind the head |
| View (short) | screen view / scoreboard | the scoreboard |
| View (short) | flashlight | |
| left stick up | move | move |
| left stick down | move | move |
| left stick left | move | move |
| left stick right | move | move |
| right stick left | turn | snap or smooth turn |
| right stick right | turn | snap or smooth turn |
| right stick up | quick load | off hand behind the head |
| right stick up | screen view / scoreboard | flat screen view, the scoreboard in multiplayer |
| right stick down | laser sight / scope steady | laser sight, steadies a scope |
| left bumper | previous weapon | |
| right bumper | next weapon | |
| D-pad up | previous weapon | alias of the left bumper |
| D-pad down | next weapon | alias of the right bumper |
| D-pad right | last weapon | |
| D-pad left | crowbar | |
| weapon hand grip | backpack weapon | the crowbar, from behind the head |
| weapon hand grip | use | alias of X: grab, push, pull, press |
| other hand grip | hold the gun steady | two-handed hold, scope: near the weapon |
| other hand grip | use (other hand) | grab with this hand: well away from the weapon |
| Menu (short) | pause | |
| Menu (hold 0.5 s) | quick save | held for half a second |
| View (hold 1 s) | recentre | |
| View (hold 3 s) | recentre and set height |
| Frame input | In a menu | Notes |
|---|---|---|
| weapon hand trigger | menu click | pointer click |
| A | menu confirm | |
| B | menu back | |
| Menu (short) | menu back | alias of B |
| View (short) | menu back | alias of B |
| D-pad up | menu up | |
| D-pad down | menu down | |
| D-pad left | menu left | |
| D-pad right | menu right | |
| left stick up | menu up | alias of the D-pad |
| left stick down | menu down | alias of the D-pad |
| left stick left | menu left | alias of the D-pad |
| left stick right | menu right | alias of the D-pad |
| left bumper | menu previous tab | previous tab or page |
| right bumper | menu next tab | next tab or page |
| right stick up | menu scroll up | scroll |
| right stick down | menu scroll down | scroll |
Aliases (one action, two inputs): use on X and the weapon hand's grip; previous / next weapon on the bumpers and D-pad up / down; flashlight on the right stick click and View; quick save on Menu held and (hand behind the head) the right stick click and View; menu back on B, Menu and View; menu up/down/left/right on the D-pad and left stick. The host test fails on any other action with two inputs, on any input doing two things in one context, and on an action nothing reaches.
5. Diagnostics (Frame build, vr-diag.ini in the data folder)
Set with tools/steam_frame/deploy.sh --diag KEY=VAL, removed with --diag-reset. No file, no effect.
| Key | Effect |
|---|---|
force_render |
draw and run the game while shouldRender is false, synthetic head at 1.6 m, hands parked in front of the chest, FOV and 64 mm eye offsets when the runtime gives none |
diag_yaw |
synthetic head yaw in degrees (turning left is positive); the hands follow |
autostart, map |
load a map without the menus after 150 rendered frames (map implies autostart; default c1a0) |
dump_frame |
on rendered frame N: vr-dumps/vr-eye0.bmp, vr-eye1.bmp (one synchronous readback each, with the driver's GL) and vr-state.txt (poses, FOVs, each eye's pose in head space, connection state, and 80 probe lines: rays from the left eye through a pixel grid traced into the world, with depth and the disparity a correct pair should show; tools/steam_frame/analyze_stereo.py matches the right eye there and compares). The log gets the driver's framebuffer bindings, viewport and scissor and the percentage of pixels that aren't black |
diag_roll |
synthetic head roll in degrees (tilting left is positive); with force_render. The HUD should stay level with the world |
diag_press |
holds buttons down: A@300+30, LG@400+20 is A from 300 frames after the map starts for 30 frames, then the left grip (names: LT RT LG RG LB RB LC RC A B X Y MENU MENUHOLD VIEW UP DOWN LEFT RIGHT, LS/RS + UP DOWN LEFT RIGHT). Runs the map with no controllers; each press is logged [vr] input -> action |
diag_gap |
metres between the synthetic hands (default 0.5), for the two-handed hold (under 0.35) against the grab (over 0.55) |
cmd |
console commands (; between them) about 300 rendered frames after the map starts: sv_cheats 1; impulse 101 for the suit and weapons, vr_hud_distance 2, vr_hand_model 0 |
probe |
right before the dump, clear each eye to a flat colour (red, blue) instead: tests the readback and the swapchain, not the game |
exit_after |
after N XR frames, leave through the normal shutdown |
perf |
[vr-perf] every 10 s, on by default when the file exists: fps, missed frames (period over 1.5x the display period), cpu ms from after the wait to after the submit, and the connection state (game=active once a map runs) |
oneshot |
rename the file to vr-diag.ini.last when it has been read |
Log prefixes: [openxr] (setup and state), [vr-diag] (what was read, autostart, exit), [vr-dump], [vr-perf], and the engine's own
messages under the tag Xash (developer level 3 by default on this build).
Eye geometry measured on the Frame (flags 0x3, valid): eye 0 at (-34.5, 0, 0) mm, eye 1 at (+34.5, 0, 0) mm, rotation 0 yaw / 0 pitch / 0 roll for
both, so 68.9 mm apart (the headset's IPD setting), fovs L -1.028 R 0.885 U 0.864 D -1.048 and L -0.877 R 1.033 U 0.866 D -1.042 rad. The engine's
fixed 65 mm was 6% short, and there is no cant. Ground-truth check on the eye images (after the fix): wall 10.2 m away 4.0 px measured against
4.05 predicted (11 probes within 0.2 px), 58.6 m away 0.1 against 0.7, walls at 2.4 to 4 m within 0.6 px, a floor point at 1.06 m 39.5
against 38.9 px; floor texture and blurred near floors don't match reliably (spread up to 8 px), which is a limit of the block matching,
not the render. HUD (c1a0 with impulse 101): 41.0 px at vr_hud_distance 1 and 21.0 px at 2 (41.4 and 20.7 expected); the old fixed shift was
2 x 48 = 96 px, about 0.43 m.
Yaw 0 and 90 dumps of c1a0 (the first corridor): the eyes differ by about the asymmetric-frustum axis offset, 90 turns the head
to the side wall. Those runs predate the release assets below, so they have no hands or weapons.
The repo git-ignores *.mdl, *.wav and assets/background, so a plain build has no v_hand, v_torch or replacement
weapons (Mod_ForName ... couldn't load). Run tools/steam_frame/fetch_release_assets.sh once before assembleFrame: it
takes the missing, ignored files from Team Beef's v1.7.4 release APK (SHA-256 pinned) and leaves tracked files alone. The APK
is then about 39 MB, like the release.
Host tests: tools/steam_frame/tests/run_tests.sh (binding tables against the profiles' component lists, unique action names,
the ini parser, the BMP writer, the booking check in deploy.sh).
6. Status
| Area | State |
|---|---|
Gradle frame build, APK content, manifest |
verified unattended: installs over adb and as the Steam devkit title Lambda1VR (shortcut appid 4086534518) |
| Steam title launch | verified unattended (2026-10-04): session SYNCHRONIZED, Frame/Index/Touch suggestions XR_SUCCESS, main menu at 72 fps, exit_after clean, Steam logs "Game process removed" |
| Release assets (hands, replacement weapons, sounds) | verified unattended: v_hand loads and the parked hands show in the eye dumps. barney_vest/barney_helmet and sprites/scope are still missing (upstream gaps too) |
| Khronos loader finds SteamVR | verified unattended (SteamVR/OpenXR 2.17.10) |
| Instance, session, swapchains, frame loop | verified unattended: 72 fps, 1728x1728, session SYNCHRONIZED |
| Standby handling | built, not run (force_render hides it); exit_after: verified unattended, clean |
| Exit from the SteamVR menu (EXITING) | built, not run |
| Data folder in Documents | verified unattended (Steam Half-Life loose files, no pak0.pak, load fine) |
| Bindings: Frame, Index, Touch | suggestions XR_SUCCESS unattended; host-checked against the component lists; no input tested |
| View hold recentre | built, not run |
| Diagnostics (force render, autostart, dumps, perf, exit_after, probe) | verified unattended |
| Eye dumps at yaw 0 and 90 | verified unattended (yaw 180 not done); stereo parity test not started |
| Eyes drawn and submitted from the runtime's eye poses | verified unattended (eye images against traced depths); not worn |
HUD at vr_hud_distance |
verified unattended at 1 and 2 m (disparity); not worn |
| Menu laser along the aim pose, quad layout | not started |
| Depth layer, motion vectors, foveation | not started |
| Controller map (HL25 gamepad layout, grips as hands, menu navigation) | built, host table test passes; the buttons ran unattended through diag_press on 2026-10-04 and logged the intended actions; not worn |
HUD, text and camera-facing sprites kept level when the head rolls (vr_hud_level) |
HUD verified unattended at roll +-20 (it turned with the world once the sign was fixed: +20.5 / -20.5 deg against +-20); sprites built, not measured; not worn |
| Menu laser on top of the screen (aim pose ray, beam and dot as layers after the screen) | built, maths host-tested; not run on the device |
Half-Life from Steam in place (XASH3D_RODIR) |
built; not run on the device |
vr_refresh_rate, vr_resolution_scale (default 1.25 = 2160) |
built; not run on the device |
| 120 Hz and 2160 measurement | blocked: the Frame was offline |
| Comfort set, haptics table, VR settings page | not started |
| Performance at the Frame's eye size | not started |