Files

15 KiB

Lambda1VR on the Valve Steam Frame

This is a Steam Frame build of Lambda1VR for the Frame's Lepton Android container. It uses ARM64, OpenXR and GLES, with Steam Frame controller bindings and a laser pointer for menus. The game keeps its head-tracked stereo view and adds a flat-screen viewing option.

I've played it on my Frame and found it smooth, sharp and clean. The in-game laser sight and several upstream assets remain known gaps. See the main README for the game data requirements and port features.

Build the APK

Build on x86-64 Linux or macOS, or on Windows through WSL2 with an x86-64 Ubuntu distribution. These steps use Bash. On Apple Silicon, install Rosetta for the pinned NDK's x86-64 host tools. Steam Frame is ARM64 and is not a host for this Android toolchain. Android's NDK downloads list the available host packages.

Windows with WSL2

Install WSL 2 and Ubuntu. From PowerShell run as Administrator, install Ubuntu with:

wsl --install -d Ubuntu

Restart Windows if prompted, open Ubuntu, and create its Linux user. Do the build in Ubuntu, with the checkout and Android SDK under Linux ~ (for example ~/src), not under /mnt/c. Install the Linux JDK and tools inside Ubuntu; a Windows JDK or Android SDK is not used by the WSL build:

sudo apt update
sudo apt install -y openjdk-17-jdk git curl unzip zip perl python3-venv openssh-client
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
mkdir -p "$ANDROID_HOME/cmdline-tools/latest" /tmp/android-cmdline-tools
curl -fL -o /tmp/commandlinetools-linux.zip \
  https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip
unzip -q /tmp/commandlinetools-linux.zip -d /tmp/android-cmdline-tools
mv /tmp/android-cmdline-tools/cmdline-tools/* "$ANDROID_HOME/cmdline-tools/latest/"

The Linux command-line tools archive and sdkmanager are documented on Google's Android Studio downloads and sdkmanager pages. Keep this Ubuntu terminal open so the environment variables remain set.

Clone the public Frame branch and its submodules into the Linux home directory:

mkdir -p ~/src && cd ~/src
git clone --recurse-submodules --branch frame-dev https://github.com/astromaddie/lambda1-vr.git
cd lambda1-vr

Install JDK 17, Android SDK command-line tools and Git. This branch uses Gradle 8.2.1, Android Gradle Plugin 8.2.1, Android SDK platform 32, build-tools 29.0.3 and NDK 25.1.8937393. On Linux/macOS only, set these variables to the JDK 17 and Android SDK locations for that host (skip this block if you followed the Windows/WSL setup above):

export JAVA_HOME="/path/to/jdk-17"
export ANDROID_HOME="/path/to/android-sdk"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"

Accept the Android SDK licenses, then install the pinned packages in the same terminal:

sdkmanager --sdk_root="$ANDROID_HOME" --licenses
sdkmanager --sdk_root="$ANDROID_HOME" \
  "platform-tools" "platforms;android-32" "build-tools;29.0.3" "ndk;25.1.8937393"

For Linux and macOS, install the command-line tools for that host from Google's Android Studio downloads page and put their cmdline-tools contents under <android-sdk>/cmdline-tools/latest before running the setup above.

On Windows through WSL2, clone from Ubuntu into ~/src and run the build there. A Windows file such as C:\Users\WindowsUser\Documents\valve appears to WSL as /mnt/c/Users/WindowsUser/Documents/valve; replace WindowsUser with the Windows account name. The Frame game reads Half-Life files from ~/Documents/Lambda1VR/valve on the headset.

The first build needs the release assets that are not stored in Git. From the repository root, make sure curl, unzip and shasum are available, then run the checked asset-fetch script and build the Frame variant:

bash tools/steam_frame/fetch_release_assets.sh
cd Projects/Android
sh ./gradlew assembleFrame

The script downloads Team Beef's public v1.7.4 release APK, verifies its SHA-256, and restores missing ignored assets. The build also downloads and verifies the Khronos OpenXR loader. Both downloads need an internet connection. The APK is Projects/Android/build/outputs/apk/frame/lambda1vr-frame.apk and uses the debug key.

Running the checked asset-fetch script before Gradle is required for a source build. The restored files are bundled into the APK; on first launch, the app copies extras.pak to its private files directory and copies the bundled replacement models, sounds and menu images into the Frame's ~/Documents/Lambda1VR data folder. You do not need to copy these release assets to the Frame separately.

The Frame menu tile is generated when the release background image and Python 3 with Pillow and NumPy are available. To install those optional modules, run python3 -m venv tools/steam_frame/.venv and tools/steam_frame/.venv/bin/pip install pillow numpy from the repository root. Without the image or modules, the build still completes with the release tile and its Quest button hints.

Install and run

If Steam's Devkit Client is running on Windows, copy the built APK from WSL to a Windows folder so the client can browse it. From the repository root in Ubuntu:

windows_user="$(cmd.exe /C echo %USERNAME% | tr -d '\r')"
mkdir -p "/mnt/c/Users/$windows_user/Downloads/Lambda1VR"
cp Projects/Android/build/outputs/apk/frame/lambda1vr-frame.apk \
  "/mnt/c/Users/$windows_user/Downloads/Lambda1VR/"

Use Valve's SteamOS Devkit Client from Steam on your desktop. Pair it once with the Frame: enable Developer Mode, choose Settings > Developer > Pair new host, then register the Frame in the client's Devkits tab and confirm pairing on the headset.

For a first install, open Devkits > Connect to devkit by IP, choose Title Upload, and set:

  • Name: Lambda1VR
  • Local Folder: The folder containing the APK: the Windows Downloads folder copied above on WSL, or Projects/Android/build/outputs/apk/frame on Linux/macOS.
  • Start Command: lambda1vr-frame.apk
  • Runtime: Android

Upload and start the title. It then appears in the Frame's Steam library under Library > Non-Steam > Devkit Game. For later builds, upload again with the same title name to update that library entry. Steam launches provide the Frame controller input.

Game data

Use the full retail Half-Life game data from your own copy; Half-Life: Source is not compatible. The app can read Half-Life installed through Steam on the Frame, or a valve/ folder under ~/Documents/Lambda1VR/valve on the Frame. Saves, settings and the files restored from the v1.7.4 release assets live under ~/Documents/Lambda1VR.

If you do not have Half-Life installed through Steam on the Frame, copy the retail valve/ folder from your own Half-Life installation. Enable Developer Mode and set the SteamOS password using Valve's Steam Frame setup guide. Valve's debugging guide documents SSH as steamos@frame. Set FRAME_HOST to frame or the Frame's IP address; enter the password you set when SSH asks. These commands use the host's OpenSSH ssh and scp clients; install openssh-client on Ubuntu (included in the WSL setup above). For WSL, a valve/ folder under Windows Documents appears at /mnt/c/Users/WindowsUser/Documents/valve; replace WindowsUser with your Windows account name. From the build computer, run:

export FRAME_HOST=frame  # replace with the Frame's IP if its hostname does not resolve
VALVE_DIR="/mnt/c/Users/WindowsUser/Documents/valve"  # use the local folder path on Linux/macOS
ssh "steamos@$FRAME_HOST" 'mkdir -p /home/steamos/Documents/Lambda1VR/valve'
scp -r "$VALVE_DIR"/. "steamos@$FRAME_HOST:/home/steamos/Documents/Lambda1VR/valve/"

If Half-Life is already installed through Steam on the Frame, no game-data copy is needed.

Quest and Frame LAN multiplayer

Use compatible Lambda1VR builds from the same source revision, with matching Half-Life game data, and put both headsets on the same local network. The LAN browser uses local UDP discovery and direct connections; it does not need an Internet server, NAT bypass, or an IP address typed on-head. A guest Wi-Fi network with client isolation can block discovery and direct play.

On the host, open Multiplayer > LAN game > Create game, choose a map, then select Ok. The player count starts at 2 so the listen server has a slot for its local player and one joining player. The headset that starts the map is player 1. On the other headset, open Multiplayer > LAN game, select the host in the server list, and choose Join game. Use Refresh if the host does not appear.

Keep a Frame host active during play. In the Frame build, headset standby (shouldRender=false) currently skips the engine frame, including the LAN server tick. A joining client can remain connected if the host resumes within its 60-second cl_timeout; after that the client reports a timeout and must rejoin from the LAN list. The server frees a silent client after its 125-second sv_timeout when ticking resumes. The Quest build continues its engine frame while not rendering, so its current standby behavior differs.

Controls

The Frame's controllers are a split gamepad. Left has the stick, trigger, grip, bumper, D-pad and View. Right has the stick, trigger, grip, bumper, A B X Y and Menu.

Half-Life's 25th anniversary update ships its own Steam Input layout for a gamepad (controller_configs/xbox_controller_config_standard.vdf in the game's valve folder). I used that one, so it works like the gamepad version people already know, and added the VR parts: your head looks, and the grips are your hands.

Frame Playing
Left stick Move
Left stick click Crouch (toggle). Physically ducking still works
Right stick left / right Turn (Lambda1VR's snap or smooth turn)
Right stick click Flashlight
Right stick up / down Nothing in the game
Weapon hand trigger Fire
Other hand trigger Alt fire
A Jump
B Crouch (while held)
X Use
Y Reload
Left / right bumper Previous / next weapon (fire to pick it, like in Half-Life)
D-pad left / right Previous / next weapon, same as the bumpers
D-pad up Last weapon
D-pad down Laser sight, and steadies a scope
Menu Pause menu. Held for half a second: quick save
View Flashlight (the scoreboard in multiplayer)
View, held 1 s Recentre, with a buzz
View, held 3 s Recentre and use the head height as standing height
Weapon hand grip Use from that hand: grab, push or pull things, press buttons. Behind your head it pulls the crowbar from the backpack
Other hand grip Near the weapon: the two-handed hold (steadier aim, a scope). Well away from it: use from that hand

The weapon hand is the right one, or the left one with vr_control_scheme 10. Left-handed swaps the roles that belong to a hand (the triggers, the grips, the laser, where the gun is) and leaves the buttons where they are.

The grips use what Lambda1VR already had: the game looks for things you can use near the hand and in front of it, from where the controller is, not from your head. The other grip decides what it is when you press it, by how far the hands are from each other (under 35 cm it's the gun hold, over 55 cm it's use, in between it stays what it was last time), and keeps that until you let go. The log says which it picked. vr_gesture_triggered_use has to stay on for this, it's what gives the short reach and use from the other hand. Waving a hand about doesn't use things on the Frame, double clicking jump doesn't crouch.

Kept as it was: aiming from the controller, swinging the crowbar, the flashlight beam from your off hand (vr_headtorch and vr_reversetorch still work), the two-handed scope, reaching behind your head for the crowbar, ducking by ducking, walking around the room, haptics, the highlight on things you can use. With your other hand behind your head, the right stick click is quick save and View is quick load, like the two buttons on a Quest controller. Walk (the slow key) is gone, the stick is analog.

The right stick is only for turning. Pushing it up or down does nothing in the game, so you can't hit the laser sight or anything else by accident while you turn. The crowbar is the weapon hand's grip behind your head, there's no button for it.

There's no button for the flat screen view either. It's a setting now: Pause, Configuration, Video, Video options, "Flat Screen View" (the vr_flat_screen cvar, off by default, and only in the Frame build). It puts the game on a flat screen in front of you instead of around you. The controllers only point and click in that mode, like in a menu, so it's for watching and not for playing. Menu still opens the pause menu, so you can turn it off again from there.

Frame In a menu
Weapon hand laser and trigger Point and click
A Confirm
B, Menu, View Back
D-pad, left stick Move
Left / right bumper Previous / next tab or page
Right stick up / down Scroll

If SteamVR hands the app Index or Touch controllers instead, they bind like they do on Quest (Index has no Menu for apps, so pause is the left trackpad pressed hard). The Frame layout only kicks in when the runtime says the Frame profile is active.

Display settings and performance

These are the build defaults and the display settings I prefer on the Frame.

  • Refresh is 120 Hz. That comes from SteamVR, from the per-app setting for the game in the headset's SteamVR settings. The game's own vr_refresh_rate (default 90) only picks from the rates SteamVR offers, and it offers just the one set for the app, so the app can't push it higher. It only counts when the game is started from the Steam library.
  • Each eye is 2160 x 2160. That's the 1728 SteamVR recommends times vr_resolution_scale (1.25), which is the panel's own size. There's no extra supersampling on top of that.
  • The eye images are sRGB (GL_SRGB8_ALPHA8), one swapchain per eye, three images each. No multiview.
  • No MSAA. It's one sample, plain GLES 3 framebuffers. The engine's gl_msaa is 0 too.
  • Depth is a 24 bit renderbuffer, no stencil, and it isn't handed to SteamVR as a depth layer.
  • No foveation.
  • The game's GL comes from gl4es, using its GLES 2.0 backend on top of a GLES 3 context.
  • Textures are trilinear (gl_texturemode GL_LINEAR_MIPMAP_LINEAR) with 8x anisotropic filtering (gl_anisotropy 8), no LOD bias, no picmip.
  • The shipped config.cfg has gamma 2.0 and brightness 0.8. My own copy on the headset had gamma 2.5, so that's what I was looking at.

In six recorded runs on two saves, with the Frame display in standby, the game held 120 fps at 120 Hz and 2160x2160 per eye with no missed frames. The game thread used about 3.1 ms of the 8.33 ms frame period. The Adreno ran at 903 MHz and was about 60% busy at 1728 pixels per eye and fully busy at 2160. One run each on a busy save also recorded 120 fps at 2592 and 3024 pixels per eye.