47 Commits
Author SHA1 Message Date
Madison 89fb3fc07e Clarify cross-platform Steam Frame build and installation 2026-10-11 20:20:41 +09:00
Madison 73d20c21aa Record public experimental source publication 2026-10-11 17:58:43 +09:00
Madison 41a599a871 Prepare public Lambda1VR source and remove tracked SDK paths 2026-10-11 17:46:14 +09:00
Madison b5f1779386 Record PR0 hygiene findings and publication holds 2026-10-11 16:59:23 +09:00
Madison f3773cb04d Fix LAN host slots and OpenXR loader for paired Android builds 2026-10-11 14:18:03 +09:00
Madison 1a6749b072 docs: refresh Steam Frame README and setup 2026-10-11 13:16:34 +09:00
Madison 04676eefe1 Document experimental private hold and PR0 release blockers 2026-10-11 10:29:45 +09:00
Madison 4fb7428b9e Frame map test and VR_ARCHITECTURE.md: right stick Y unbound, D-pad weapons and laser, flat screen view as a setting, the menu tile (dev only) 2026-10-05 16:48:03 +09:00
Madison 98bdb4405b VR_ARCHITECTURE.md: the reference display settings of the worn build (dev only) 2026-10-05 16:47:07 +09:00
Madison aacf6e5a0f Frame diagnostics: layer poses of the menu laser in the dump, composite_menu.py; status after the Steam-in-place, laser and 120 Hz runs (dev only) 2026-10-05 16:47:07 +09:00
Madison 2aee8ee1e1 VR_ARCHITECTURE.md: status after the controls, roll, laser, Steam-in-place and refresh work (dev only) 2026-10-05 16:47:07 +09:00
Madison 77150eeb57 Frame diagnostics: thread CPU and waits in [vr-perf], load and exit_seconds keys, perf_run.sh (dev only)
[vr-perf] now has the game thread's own CPU time per frame (CLOCK_THREAD_CPUTIME_ID), the
time in xrWaitFrame and in acquiring the swapchain image (with its wait), and the eye size, so
the split between work and waiting is there (lessons 39, 50, 62). The load key loads a save
instead of starting a map, exit_seconds leaves after that many seconds of rendering.
perf_run.sh does one run and adds the Adreno's time at each frequency over it.
2026-10-05 16:47:07 +09:00
Madison 7552d2c312 deploy.sh: don't launch while the headset isn't in standby, and say Steam is used in place (dev only)
SteamVR logs standby; with nobody wearing it, it goes to standby a few seconds after an app
closes. If it doesn't within two minutes the script refuses to launch (FRAME_SKIP_WORN_CHECK=1
overrides). The --data help says the copy is only for when Half-Life isn't in Steam.
2026-10-05 16:47:07 +09:00
Madison 3372d5af6e Frame diagnostics: aim the synthetic hands, show the pointer in the dump, level-HUD analysis (dev only)
diag_hand_yaw and diag_hand_pitch turn the synthetic hands' aim from the head's,
so the menu laser can be pointed. vr-state.txt says where the cursor is (u, v and
the pixel in the screen image). analyze_roll.py compares HUD-on and HUD-off dumps at
different rolls and says how far the world and the HUD turned. Host test for the
laser maths (ray against the quad, beam and dot poses).
2026-10-05 16:47:07 +09:00
Madison 3843dda03e VR_ARCHITECTURE.md: the Frame controller map as one table, what the grips rely on (dev only) 2026-10-05 16:47:07 +09:00
Madison a5c1c07503 Frame diagnostics: diag_roll, diag_press, diag_gap and a log line per button (dev only)
diag_roll tilts the synthetic head, to check the HUD stays level. diag_press
holds buttons down for a while ("A@300+30, LG@400+20", frames after the
map starts) so the controller map can run with nobody holding controllers:
the map code runs when there are presses to come, with the runtime's own
inputs under them. diag_gap sets how far apart the synthetic hands are, for
the two-handed hold against the grab. The map logs each press and what it
made of it. A host test builds the effective map table for both hands and
every context and fails on an input doing two things, an unlisted alias or an
action nothing reaches.
2026-10-05 16:47:07 +09:00
Madison 57aa2aea08 VR_ARCHITECTURE.md: eye poses, the HUD pass and what was measured (dev only) 2026-10-05 16:47:07 +09:00
Madison ee43034963 Frame diagnostics: eye poses and traced depth probes in the dump, cmd key (dev only)
vr-state.txt now has each eye's position and rotation in head space,
and a grid of probes: rays from the left eye through pixels of the
image, traced into the world, with the depth along the view axis and the
disparity a correct stereo pair should show there. analyze_stereo.py
block-matches the right eye at those points and prints predicted against
measured (numpy and pillow in a venv). A new cmd key runs console commands
about five seconds after the map starts, for cheats, cvars, giving the
suit to get a HUD.
2026-10-05 16:47:07 +09:00
Madison b791b82099 Note the first Steam title launch and the release assets in VR_ARCHITECTURE.md (dev only) 2026-10-05 16:47:07 +09:00
Madison ad16b48b1f Fetch the git-ignored models and sounds from the v1.7.4 release (dev only)
The repo ignores *.mdl, *.wav and the loading backgrounds, so a build from
source had no hands or replacement weapons. The script copies only the
missing, ignored files out of Team Beef's release APK, hash pinned.
2026-10-05 16:47:07 +09:00
Madison 208cb7d1c7 Frame diagnostics: read the eyes with the driver's GL, more state in the dump (dev only)
The readback used gl4es's glReadPixels, which doesn't know the swapchain's
framebuffers. It uses the driver's now. The dump logs the driver's
framebuffer bindings, viewport and scissor, the share of pixels that
aren't black and the connection state, and vr-state.txt says whether a map
is running. New probe key: clear each eye to a flat colour before the dump,
to tell a broken readback from a broken picture. [vr-perf] shows game=active
once a map is running.

VR_ARCHITECTURE.md has what the first unattended runs found.
2026-10-05 16:46:58 +09:00
Madison b5689385e1 Add VR_ARCHITECTURE.md for the Frame build (dev only)
The target, the frame protocol as it is in TBXR, a list of the draw
calls that depend on the camera or the screen with the rule each one
uses, the diagnostic keys and a status table. Nothing in it has been
run on the headset yet.
2026-10-05 16:46:57 +09:00
Madison bd8d932baf Add deploy script and host tests for the Frame build (dev only)
deploy.sh builds nothing, it installs over adb to Lepton Development,
registers the persistent Steam title, copies the game data, sets the
vr-diag.ini keys, pulls the dumps and the log. It checks the Frame
booking first and won't install or launch unless the holder is the name
passed with --lock-holder.

The host tests check the binding tables against each profile's
component list, that the action names are unique, the ini parser, the
BMP writer and the booking check.
2026-10-05 16:46:57 +09:00
Madison 231f5513a2 Add unattended test switches for the Frame build (dev only)
vr-diag.ini in the data folder, read once at start, does nothing when
it isn't there: force_render (draw and run the game while the headset
is in standby, synthetic head and hands), diag_yaw, autostart and map,
dump_frame (both eyes as BMP plus a state file), exit_after, a [vr-perf]
line every 10 seconds and oneshot (the file gets renamed once read).

The parser and BMP writer are in VrDiagCore.c so they can be tested on
the host. VrDiag.c is the glue, and the hooks in the VR files are a line
or two each behind L1VR_STEAM_FRAME.

Builds. Haven't run it on the headset yet.
2026-10-05 16:46:57 +09:00
Madison e6b3e26003 Frame: make the menu tile with the Frame's button help at build time
make_frame_tile.py reads the release tile from the git-ignored
assets/background, takes the Quest help off it, draws the Frame's, and
writes it into build/frame-assets. The frame build type's assets override
the release tile with it, a gradle task runs it before mergeFrameAssets.
Quest and Pico builds are untouched. If the tile or Pillow and numpy are
missing, the Frame build goes on with the release tile and a warning.
2026-10-05 16:46:57 +09:00
Madison 3c5d2cc813 Frame: right stick up and down do nothing, D-pad is weapons and laser, flat screen view is a setting
Right stick up/down no longer toggle the flat screen view or the laser
sight. D-pad left/right are previous/next weapon, up is the last weapon
and down toggles the laser sight. The D-pad crowbar is gone, the crowbar
is the grip behind the head. With the other hand behind the head, the
right stick click is quick save and View is quick load.

The flat screen view is the vr_flat_screen cvar now, with a Flat Screen
View checkbox on the Video options page in the Frame build only. In
multiplayer, View still toggles the scoreboard.
2026-10-05 16:45:00 +09:00
Madison 5ee2b12181 Doc: the reference display settings for the Frame build 2026-10-05 10:51:10 +09:00
Madison afbc400cfe Frame: size the eye image from vr_resolution_scale, ask for a refresh rate
The Frame build rendered the runtime's 1728 square recommendation, but the
panel is 2160 square. The eye image is now the recommended size times
vr_resolution_scale (archived, 1.25 by default, which is 2160), held to what
the view (maxImageRect) and the swapchain allow; the engine's screen size is
set to match before the swapchains are made. The log has the recommended size,
the largest the view and swapchain take, and what was used. The HUD shift
and the rest work from the real width, so they follow.

vr_refresh_rate (archived, default 90, standard section 9) is asked for
with xrRequestDisplayRefreshRateFB: the highest the runtime offers that isn't
over it. The offered list, the request and the result are logged. The
earlier "now: 90 Hz" at a 13.89 ms frame period was a mismatch in what
was read, not asked for; vr_refresh follows the real frame period.

Frame build only. Builds, haven't run it on the headset yet.
2026-10-05 02:02:49 +09:00
Madison a4f12f84fd Frame: use Half-Life from Steam where it is, read only
The Android container on the Frame shows the headset's Steam folders at the
same paths, read only. At start the app looks for steamapps/common/Half-Life
(valve/liblist.gam) in the Steam library and in the other libraries
libraryfolders.vdf lists, and gives the engine that folder as XASH3D_RODIR.
The engine searches it after the writable folder, so the saves, the
settings and the files this app puts in /sdcard/Documents/Lambda1VR (the
replacement models and sprites) win over Steam's. Nothing is written into the
Steam folder. If there's no Half-Life there, a copy in Documents is used as
before, and a [data] line in the log says which one it was.

Builds. Haven't tried it on the headset yet.
2026-10-04 23:38:22 +09:00
Madison 571fed0ef5 Frame: the menu laser, always on top of the screen
The menu cursor was worked out from the controller's angles, not from
where its ray meets the screen, and no beam was drawn. Now the ray from the
weapon hand's aim pose (aim/pose, -z, the runtime's own pointing ray, not
the grip) is cast against the flat screen quad, and the cursor is where it
lands. A beam from the controller to that point, and a dot where it meets
the panel, are quad layers of their own, submitted after the screen, so
SteamVR draws them over it and nothing in the eye image (or the HUD, or
the world) can cover them. They show only while the screen is up and the
hand is tracked. The beam is a thin quad turned about its axis to face the
eye; the little swapchain behind both is one flat colour.

Frame build only. Builds, haven't had it in the headset yet.
2026-10-04 23:34:56 +09:00
Madison c97235b543 Frame: turn the level HUD the right way, and keep camera-facing sprites level
The first level-HUD commit turned the HUD the same way as the world instead
of against it (found by comparing eye dumps with the HUD on and off, at
roll +-20: the HUD turned 20 degrees the opposite way to the world).
Fixed, and it now follows the world.

Camera-facing sprites (plain sprites, glows, muzzle flashes, the oriented
parallel ones before their own roll) face the view with the head's roll
taken out, so they stay upright in the world when the head tilts. Not in a
scope. vr_hud_level 0 turns both off.
2026-10-04 23:30:55 +09:00
Madison f32023fff2 Frame: vr_hud_level, to turn the level HUD off
1 (the default) keeps the HUD and the text of scripted sequences level
with the world when the head rolls, 0 lets them roll with the head like
they did. Mostly so the difference can be measured, but some people may
want the old way.
2026-10-04 23:01:59 +09:00
Madison 45c66088fd Doc: the Frame controls, from Half-Life's own gamepad layout, and the level HUD 2026-10-04 22:10:56 +09:00
Madison 51c2ffd4d5 Frame: keep the HUD and the text of scripted sequences level when the head rolls
The 2D pass is drawn into the eye image, so it rolled with the head. Now
the HUD pass (the HUD, game_text and titles such as the tram credits,
the centre print, the pause icon) is turned the other way by the roll the
world is drawn with, about the optical axis, before the HUD distance shift,
which is along the image because the eyes are. So it follows the head's
yaw and pitch but not its roll.

The screen layer (menus) is a world-locked quad that never rolls with the
head, and a scope is left as it was. The screen fade stays unrotated so it
still covers the whole image.

Frame build only. Builds, haven't had it in the headset yet.
2026-10-04 22:08:22 +09:00
Madison b126446f9c Frame: use Half-Life's own gamepad layout for the controllers
The Frame's controllers had the Quest layout moved around, with the
torch on a bumper, crouch on A and so on. Half-Life's 25th anniversary
update ships its own Steam Input layout for a gamepad, so this follows
that, and adds the VR parts: the grips, and the head for looking.

Playing: left stick moves (click toggles crouch), right stick turns
(Lambda1VR's snap or smooth turn), right stick click is the flashlight,
the weapon hand's trigger fires and the other trigger alt fires, A jump,
B crouch while held, X use, Y reload, bumpers previous and next weapon,
D-pad up and down the same, right is the last weapon and left the
crowbar. Menu pauses and, held for half a second, quick saves. View
taps the flashlight (the scoreboard in multiplayer), held for one
second recentres, three seconds recentres and takes the head height.

Grips are for hands: the weapon hand's grip is use from that hand
(the game already finds what's near the hand and in front of it), or the
crowbar when pressed with the hand behind the head. The other hand's
grip is the two-handed hold when that hand is near the weapon, and use
from that hand when it's well away from it. That's decided when the grip
goes down (hysteresis between 35 and 55 cm), and logged.

In a menu the weapon hand's laser and trigger click, A confirms, B and
View go back, the D-pad and the left stick move, the bumpers page, the
right stick scrolls.

The right stick up flicks the flat screen view and down the laser sight,
as those two have nowhere else to go. Walk (+speed) is gone, the stick is
analog. With the other hand behind the head, the torch and screen view
buttons are quick save and load, as on the Quest.

The map is one table (VrFrameMap.c) and VrInputFrame.c turns it into what
the scheme code reads, so it's easy to change. Double clicking jump for
crouch and automatic use by waving a hand are off on the Frame (saved
settings are updated once); vr_gesture_triggered_use stays on, because
it's what makes the game take use from each hand within a short reach.

Builds. Haven't had it in the headset yet.
2026-10-04 22:06:54 +09:00
Madison 0220e23845 Doc: eye poses and the HUD distance on the Frame
What the runtime said about the eyes on my headset (+-34.5 mm, no
rotation), what's drawn and submitted from that now, where the HUD
sits and how to move it, and what I measured on the eye images.
2026-10-04 21:24:01 +09:00
Madison 1eac3db3ce Frame: put the HUD at vr_hud_distance
The client DLL pushes every HUD item sideways by width / 36 per eye
(it works that out from vr_stereo_side). On the Frame's lenses that's
about 4.5 degrees an eye, so the HUD sat around 0.4 m away and was
hard on the eyes. The DLL is a submodule, so while it draws the HUD the
eyes are given as the mono ones (which is zero for it), and the whole
HUD pass is shifted here instead, by the pixels that put it
vr_hud_distance metres ahead given that eye's real position and focal
length. That also puts the centre print and the engine's FPS counter at
the same place. Scope mode and the screen layer are left alone.

vr_hud_distance is archived, in metres, and defaults to 1.0. The shift
for each eye is logged once.

Frame build only. Builds, haven't had it in the headset yet.
2026-10-04 21:06:56 +09:00
Madison 02e68f7916 Frame: draw each eye from the runtime's eye pose
The engine moves each eye a fixed 65 mm times vr_worldscale along the
side and draws both with the head's rotation, and the layer was
submitted with the head's pose for both views. On the Frame the runtime
says where each eye is and which way it points, so use that:

- the world is drawn from each eye's position in head space, scaled by
  vr_worldscale, with the eye's turn relative to the head if it has one
- each projection view is submitted with that eye's pose (the head's
  placed in the stage, then the eye's in the head), from the same
  predicted time the render used
- the mono eyes of a scope and the flat screen layer keep drawing from
  one point, as before

The eye maths is in VrEyeMath.c, which has no engine or OpenXR types
so it can be tested on the host. Frame build only.

Builds. Haven't had it in the headset yet.
2026-10-04 21:06:51 +09:00
Madison 6ff4159d3c Frame: log what the runtime says about each eye
Position and rotation of each eye in head space, the fov and the eye
separation, once at the start, and again when the poses first are valid.
Needed to check the stereo against what the engine assumes.
2026-10-04 20:58:15 +09:00
Madison daec3490e7 Update the Steam Frame doc after the first run on the headset
Unattended on the desk, but it runs: loader, session, first level in both
eyes, clean quit. Also the three Frame fixes (framebuffer attachments,
engine log, refresh rate) and the JavaVM one, which touches Quest too.
2026-10-04 19:13:10 +09:00
Madison ce8b7f8d6d Frame: set the swapchain attachments with the driver's GL
The first run on the Frame had both eyes' framebuffers come back as
GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT, and the eyes stayed black. The
plain gl* names in libxash.so are gl4es's (it's linked first), and gl4es
swaps a texture name it hasn't seen for a new empty texture of its own,
so the swapchain image never made it into the attachment. The EXT path
doesn't have the problem since it gets its functions from eglGetProcAddress.
The plain GLES 3 fallback I added does the same now. The framebuffer is
still made and bound through gl4es, because gl4es refuses to bind a
framebuffer it didn't make and the engine draws through it.

Also on the Frame build:
- the engine's messages show up in logcat (tag Xash). They always went
  there, but the default developer level is 0 and the commandline.txt
  from the assets has no -dev, so nothing was printed. The default is
  3 there now, -dev on the commandline still wins.
- vr_refresh comes from the frame period instead of the list of rates
  the runtime offers. SteamVR picks the rate per app, it said 90 on
  offer while the display ran at 72, and the positional movement
  scaling uses vr_refresh.

Builds. Haven't had it in the headset yet.
2026-10-04 19:10:33 +09:00
Madison 5c2769c0ea Set the JavaVM pointer that jni_shutdown uses
jVM is a static that nothing assigns, so jni_shutdown dereferences NULL.
The compiler takes that as can't-happen and drops the code after the
call, so when the game quits (AppThreadFunction after Host_Shutdown and
TBXR_LeaveVR) it runs off the end of the function into whatever comes
next. On the Frame that was a call to a fortified snprintf with garbage
arguments and an abort. On the Quest it probably crashes or does
something random on the way out too, it just doesn't matter there since
the app is closing anyway.

JNI_OnLoad gets the VM, so keep it there.
2026-10-04 19:05:49 +09:00
Madison 3e9c44b38b Add a doc for the Steam Frame build
How to build it, install it, where the game data goes, what the controls
are, what I changed and why, and what I've checked so far (not much on
the headset side, I haven't tried it there yet).
2026-10-04 18:47:00 +09:00
Madison 247d8be660 Keep the game data in Documents on the Frame
On the Quest the data is in /sdcard/xash. On the Frame that would be gone
whenever the Android container gets reset, so it uses
/sdcard/Documents/Lambda1VR there, which is the headset's own
~/Documents/Lambda1VR. The Java side, the chdir and the engine's
default path all follow it. The Quest path is the same as before.

The container on the Frame has no settings page for the all files
permission, so on that build we just check we can write to the folder
and only ask if we can't (and don't fall over if there's no page).
2026-10-04 18:44:21 +09:00
Madison afe3c6f890 Add Valve Frame controller bindings
On the Frame build the bindings go to the Frame's own profile first
(valve/frame_controller_valve), then Index, then Touch. The Pico profile
isn't tried there. Every profile logs what the runtime said to the
suggestion.

The game's actions stay the same: sticks, triggers, grips, stick clicks
and A/B are where they are on Quest. The Frame has no X/Y on the left
controller, so the torch is the left bumper (or the right X) and the
screen view is View (or the right Y). Menu is the right one and is the
pause. Holding View for a second recentres, three seconds also takes the
head height as the standing height, with a buzz on both controllers.
The left-handed schemes get the same physical buttons.

The layout only kicks in when the runtime says the Frame profile is
the active one, so Index or Touch controllers on SteamVR behave like they
do on Quest. Index has no Menu for apps, so its pause is the left
trackpad pressed hard.

Builds. Haven't tried it in the headset yet.
2026-10-04 18:43:20 +09:00
Madison 9ad98dcf51 Make the OpenXR setup work on SteamVR for the Frame build
SteamVR doesn't offer what the Quest and Pico runtimes do, and the frame
loop assumed they were there. All of this is behind L1VR_STEAM_FRAME, so
the Quest and Pico code paths stay as they were.

- Only OpenGL ES is required. Everything else (android create instance,
  performance settings, refresh rate, the Valve controller extension) is
  enabled when the runtime lists it. The android create instance struct
  only goes in the chain if that extension is on.
- No Pico or Meta calls on the Frame, and no assuming OPENXR_HMD is set.
- The graphics binding gets the EGLConfig instead of the surface.
- The eye framebuffers fall back to plain GLES 3 when there's no
  multisampling, so the runtime doesn't need the EXT extension.
- Render at the recommended size, not 1.3x of it.
- In standby (shouldRender false) the frame goes in with no layers and
  the game waits too. Before, the server kept simulating.
- SteamVR quitting the app (exiting, loss pending) goes through the
  normal shutdown instead of spinning on events.
- A recentre that takes the head's yaw and floor position, for the
  controller code to call. It keeps the view yaw and the world position,
  so nothing jumps.
- Log what's going on with an [openxr] prefix: runtime, extensions,
  swapchain format and size, session state, refresh rates.

Builds. Haven't tried it in the headset yet.
2026-10-04 18:41:58 +09:00
Madison b3058f76f6 Add a Steam Frame build type with the Khronos OpenXR loader
The Frame runs Android apps in a container (Lepton) that has no OpenXR
loader broker, so the APK needs its own loader. This adds a "frame" build
type: ./gradlew assembleFrame.

It's release plus:
- applicationId .frame, minSdk 30 (the container is Android 11)
- a manifest overlay that drops the headtracking requirement and the
  Quest/Pico metadata, since the install fails with it
- the Khronos loader 1.1.63, fetched from Maven Central at build time,
  checked against a SHA-256 and unpacked into build/ (not committed)
- L1VR_STEAM_FRAME for the native code, BuildConfig.STEAM_FRAME for Java

Quest builds don't see any of this. I also added the gradle wrapper script
to Projects/Android, since the wrapper jar and properties were already
there, and -Wall for the VR sources.

Haven't run it on the headset yet.
2026-10-04 18:36:18 +09:00
63 changed files with 6612 additions and 440 deletions

No files matched your search

+2
View File
@@ -15,3 +15,5 @@
*.PDF diff=astextplain
*.rtf diff=astextplain
*.RTF diff=astextplain
# Preserve the exact archived upstream README, including its original whitespace.
docs/README_PRE_PR0.md -text -eol whitespace=-blank-at-eol
+12
View File
@@ -69,3 +69,15 @@ build/intermediates/lint-cache/maven.google/master-index.xml
build/intermediates/lint-cache/sdk-registry.xml/sdk-registry.xml
assets/AoMDC/models/gmgeneral_display.aomdc
/assets/background
# Steam Frame build: the Khronos OpenXR loader gets fetched here
Projects/Android/build/
# Steam Frame build: Python for the menu tile
tools/steam_frame/.venv/
# Frame dev: run output from deploy.sh
scratchpad/
# Local Android SDK configuration
/local.properties
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 2026-10-11 — PR0 preparation
- Updated the Steam Frame README and build information.
- Recorded source revisions, existing verification and the specific publication blockers.
- Preserved upstream notices and kept game data separate from the source license.
## 2026-10-11 — public source preparation
- Prepared the public Frame source fork, checked the host suite and removed tracked machine-specific SDK paths.
+116 -1
View File
@@ -11,6 +11,74 @@ buildscript {
apply plugin: 'com.android.application'
// Steam Frame build (./gradlew assembleFrame, see docs/STEAM_FRAME_VR.md).
// The Frame's Android container has no OpenXR loader broker, so the Khronos
// loader goes into the APK. It comes from Maven Central, gets checked against
// this hash and is unpacked into build/ (never committed). Quest builds don't
// touch any of this.
def khronosLoaderVersion = '1.1.63'
def khronosLoaderSha256 = '622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73'
def khronosLoaderDir = file("build/openxr-loader/${khronosLoaderVersion}")
def khronosLoaderSo = new File(khronosLoaderDir, 'libopenxr_loader.so')
def fetchKhronosLoader = tasks.register('fetchKhronosLoader') {
outputs.file khronosLoaderSo
doLast {
def url = "https://repo1.maven.org/maven2/org/khronos/openxr/openxr_loader_for_android/${khronosLoaderVersion}/openxr_loader_for_android-${khronosLoaderVersion}.aar"
def aar = new File(khronosLoaderDir, 'openxr_loader_for_android.aar')
khronosLoaderDir.mkdirs()
new URL(url).withInputStream { input -> aar.withOutputStream { it << input } }
def digest = java.security.MessageDigest.getInstance('SHA-256')
aar.eachByte(8192) { buf, len -> digest.update(buf, 0, len) }
def sha = digest.digest().collect { String.format('%02x', it) }.join()
if (sha != khronosLoaderSha256) {
aar.delete()
throw new GradleException("OpenXR loader AAR has the wrong SHA-256 (${sha}), expected ${khronosLoaderSha256}")
}
def zip = new java.util.zip.ZipFile(aar)
try {
def entry = zip.getEntry('jni/arm64-v8a/libopenxr_loader.so')
if (entry == null) throw new GradleException('libopenxr_loader.so (arm64-v8a) is not in the loader AAR')
zip.getInputStream(entry).withStream { input -> khronosLoaderSo.withOutputStream { it << input } }
} finally {
zip.close()
}
aar.delete()
}
}
// Steam Frame build: the menu background tile with the Frame's button mapping on it. The release
// tile (800_1_a_loading.tga, Team Beef's art, git-ignored) has the Quest's help baked in, so
// tools/steam_frame/make_frame_tile.py makes a Frame one into build/frame-assets, and the frame build
// type's assets override the release one with it. Nothing generated is committed, and Quest and Pico
// builds don't see this. If the release tile or Python with Pillow and numpy is missing, the Frame
// APK still builds with the release tile, and this says so.
// Python is -PframePython, $FRAME_PYTHON, tools/steam_frame/.venv, or python3, in that order.
def frameAssetsDir = file('build/frame-assets')
def makeFrameTile = tasks.register('makeFrameTile') {
doLast {
delete frameAssetsDir // never ship a stale tile after the source went away
def venvPython = file('../../tools/steam_frame/.venv/bin/python')
def python = project.findProperty('framePython') ?: System.getenv('FRAME_PYTHON') ?:
(venvPython.exists() ? venvPython.absolutePath : 'python3')
def status = -1
try {
status = exec {
commandLine python, file('../../tools/steam_frame/make_frame_tile.py').absolutePath,
'--src', file('../../assets/background/800_1_a_loading.tga').absolutePath,
'--out', new File(frameAssetsDir, 'background/800_1_a_loading.tga').absolutePath
ignoreExitValue = true
}.exitValue
} catch (Exception e) {
logger.warn("Could not run ${python}: ${e.message}")
}
if (status != 0) {
logger.warn('WARNING: no Frame menu tile, the Frame APK gets the release one (with the Quest button help). ' +
'See "Build" in docs/STEAM_FRAME_VR.md.')
}
}
}
android {
namespace = "com.drbeef.lambda1vr"
@@ -40,6 +108,13 @@ android {
minSdkVersion 32
targetSdkVersion 32
// True only in the frame build type
buildConfigField 'boolean', 'STEAM_FRAME', 'false'
}
buildFeatures {
buildConfig true
}
externalNativeBuild {
@@ -103,6 +178,29 @@ android {
}
}
}
// Valve Steam Frame (Lepton, Android 11). Same as release, plus a
// manifest overlay (src/frame) and L1VR_STEAM_FRAME for the native code.
// Signed with the debug key, it's only sideloaded.
frame {
initWith release
matchingFallbacks = ['release']
applicationIdSuffix ".frame"
versionNameSuffix "-frame"
signingConfig signingConfigs.debug
buildConfigField 'boolean', 'STEAM_FRAME', 'true'
externalNativeBuild {
ndkBuild {
arguments "L1VR_STEAM_FRAME=1", "APP_PLATFORM=android-30"
}
}
}
}
sourceSets.frame {
// generated by makeFrameTile, wins over the release tile in main
assets.srcDir frameAssetsDir
}
sourceSets.main {
@@ -133,6 +231,23 @@ android {
ndkVersion '25.1.8937393'
}
// Lepton runs Android 11, the Quest build needs 12L (API 32)
androidComponents {
beforeVariants(selector().withBuildType('frame')) { variantBuilder ->
variantBuilder.minSdk = 30
}
}
// The loader has to be there before ndk-build links against it
tasks.configureEach { task ->
if (task.name in ['preBuild', 'preDebugBuild', 'preReleaseBuild', 'preFrameBuild']) {
task.dependsOn fetchKhronosLoader
}
if (task.name == 'mergeFrameAssets') {
task.dependsOn makeFrameTile
}
}
dependencies {
implementation "com.android.support:support-compat:28.0.0"
implementation "com.android.support:support-core-utils:28.0.0"
@@ -148,4 +263,4 @@ repositories {
// not found error
if (!tasks.findByName("prepareKotlinBuildScriptModel")) {
tasks.register("prepareKotlinBuildScriptModel") {}
}
}
Vendored Executable
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MSYS* | MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -13,6 +13,10 @@
#include "VrInput.h"
#include "VrCvars.h"
#include "VrCommon.h"
#ifdef L1VR_STEAM_FRAME
#include "VrEyeMath.h"
#include "VrDiag.h"
#endif
#include <common/common.h>
#include <common/library.h>
@@ -28,6 +32,13 @@
//Let's go to the maximum!
extern float SS_MULTIPLIER;
//Where the game data is, the same folder the Java side uses
#ifdef L1VR_STEAM_FRAME
#define L1VR_DATA_DIR "/sdcard/Documents/Lambda1VR"
#else
#define L1VR_DATA_DIR "/sdcard/xash"
#endif
/* global arg_xxx structs */
struct arg_dbl *ss;
struct arg_int *cpu;
@@ -53,6 +64,12 @@ extern int Host_Main( int argc, const char **argv, const char *progname, int bCh
bool VR_UseScreenLayer()
{
#ifdef L1VR_STEAM_FRAME
if (vr_flat_screen != NULL && vr_flat_screen->integer != 0)
{
return true;
}
#endif
return (showingScreenLayer || cls.demoplayback || cls.state == ca_cinematic || cls.key_dest != key_game);
}
@@ -316,6 +333,98 @@ void VR_Get2DOffset(int eye, int width, int height, float *dx, float *dy)
*dy = (tanU + tanD) / (2.0f * (tanU - tanD)) * (float)height;
}
#ifdef L1VR_STEAM_FRAME
// which way the 2D space turns for a positive view roll (it's y-down, so the other way to the world)
#define HUD_ROLL_SIGN -1.0f
/*
Where an eye is for the world render, from the runtime's own eye pose in head space (the same
poses and predicted time the layer is submitted with). offset is in world units, in the
head-aligned engine axes. cantDegrees is the eye's turn relative to the head about cantAxis,
0 when there is none. False when the eye isn't to be moved: the mono eyes of a scope, and the
flat screen layer, where it's the middle that's wanted.
*/
bool VR_GetEyeTransform(int eye, float worldScale, vec3_t offset, float* cantDegrees, vec3_t cantAxis)
{
*cantDegrees = 0.0f;
if (eye < 0 || eye >= ovrMaxNumEyes || !gAppState.SessionActive || gAppState.Projections == NULL || VR_UseScreenLayer())
return false;
const XrPosef* pose = &gAppState.Projections[eye].pose;
const float position[3] = {pose->position.x, pose->position.y, pose->position.z};
const float quat[4] = {pose->orientation.x, pose->orientation.y, pose->orientation.z, pose->orientation.w};
float offsetOut[3];
VrEye_ToEngineOffset(position, worldScale, offsetOut);
VectorSet(offset, offsetOut[0], offsetOut[1], offsetOut[2]);
float axis[3];
float degrees;
if (VrEye_CantAxisAngle(quat, axis, &degrees))
{
VectorSet(cantAxis, axis[0], axis[1], axis[2]);
*cantDegrees = degrees;
}
return true;
}
/*
How far to shift the 2D content (the HUD) in an eye's image, in pixels, positive to the right,
so that it sits vr_hud_distance metres ahead of the head. 0 for the mono eyes and the screen layer.
*/
float VR_GetHudShift(int eye)
{
if (eye < 0 || eye >= ovrMaxNumEyes || !gAppState.SessionActive || gAppState.Projections == NULL ||
VR_UseScreenLayer() || vr_hud_distance == NULL)
return 0.0f;
const XrView* view = &gAppState.Projections[eye];
const int width = gAppState.Renderer.FrameBuffer[eye].Width;
const float shift = VrEye_HudShiftPixels(view->pose.position.x, view->fov.angleLeft, view->fov.angleRight,
width, vr_hud_distance->value);
static bool logged[ovrMaxNumEyes];
if (!logged[eye])
{
logged[eye] = true;
ALOGI("[openxr] hud: eye %d at x %.1f mm, focal length %.0f px, vr_hud_distance %.2f m, shift %.1f px",
eye, view->pose.position.x * 1000.0f, VrEye_FocalPixels(view->fov.angleLeft, view->fov.angleRight, width),
vr_hud_distance->value, shift);
}
return shift;
}
/*
How far to turn the 2D content (the HUD and the text of scripted sequences) so that it stays level
with the world when the head rolls: the roll the world is drawn with, the other way. 0 on the screen
layer (a world-locked panel, it never rolls with the head) and in a scope.
*/
/*
Whether the camera-facing sprites (a plain sprite, glows, muzzle flashes) face the view with the roll taken
out, so they stay upright in the world when the head tilts, as the HUD does. Not in a scope, where the whole
view is the gun's.
*/
bool VR_BillboardsLevel()
{
return gAppState.SessionActive && !VR_UseScreenLayer() && !isScopeEngaged() && vr_hud_level != NULL && vr_hud_level->integer != 0;
}
float VR_GetHudRoll(int eye)
{
if (eye < 0 || eye >= ovrMaxNumEyes || !gAppState.SessionActive || VR_UseScreenLayer() || isScopeEngaged() ||
vr_hud_level == NULL || vr_hud_level->integer == 0)
return 0.0f;
const float roll = cl.refdef.viewangles[ROLL];
static bool logged = false;
if (!logged && fabsf(roll) > 5.0f)
{
logged = true;
ALOGI("[vr] hud kept level: the view rolls %.1f degrees, the HUD is turned the other way", roll);
}
return HUD_ROLL_SIGN * roll;
}
#endif
void R_ChangeDisplaySettings( int width, int height, qboolean fullscreen );
int VR_SetRefreshRate(int refreshRate)
@@ -395,6 +504,13 @@ convar_t *vr_quick_crouchjump;
convar_t *vr_stereo_side;
convar_t *vr_gesture_triggered_use;
convar_t *vr_use_gesture_boundary;
#ifdef L1VR_STEAM_FRAME
convar_t *vr_hud_distance;
convar_t *vr_hud_level;
convar_t *vr_refresh_rate;
convar_t *vr_resolution_scale;
convar_t *vr_flat_screen;
#endif
void initialize_gl4es();
@@ -451,9 +567,34 @@ void VR_Init()
vr_highlight_actionables = Cvar_Get( "vr_highlight_actionables", "1", CVAR_ARCHIVE, "Set to 0 to disable highlighting of actionable objects/entities" );
vr_headtorch = Cvar_Get( "vr_headtorch", "0", CVAR_ARCHIVE, "Set to 1 to enable head-torch flashlight mode" );
vr_reversetorch = Cvar_Get( "vr_reversetorch", "0", CVAR_ARCHIVE, "Set to 1 to enable reverse-direction flashlight mode" );
#ifdef L1VR_STEAM_FRAME
// The Frame has a button for crouch, so double clicking jump isn't also crouch unless asked for.
// And use is the grips: this cvar makes the game take use from each hand, within a short reach of it
// (the automatic use by gesture is off on the Frame, the grips do it)
vr_quick_crouchjump = Cvar_Get( "vr_quick_crouchjump", "0", CVAR_ARCHIVE, "Set to 1 to enable quick crouch-jump mode (double clicking jump button triggers duck)" );
vr_gesture_triggered_use = Cvar_Get( "vr_gesture_triggered_use", "1", CVAR_ARCHIVE, "On the Steam Frame: 1 is a short reach from each hand for the grips to use (0 is the old 64 units from the weapon hand, and no use from the other hand)" );
#else
vr_quick_crouchjump = Cvar_Get( "vr_quick_crouchjump", "1", CVAR_ARCHIVE, "Set to 0 to disable quick crouch-jump mode (double clicking jump button triggers duck)" );
vr_gesture_triggered_use = Cvar_Get( "vr_gesture_triggered_use", "1", CVAR_ARCHIVE, "Set to 0 to disable use gesture, 1 to enable" );
#endif
vr_use_gesture_boundary = Cvar_Get( "vr_use_gesture_boundary", "0.35", CVAR_ARCHIVE, "Use gesture boundary" );
#ifdef L1VR_STEAM_FRAME
{
// The Frame's controller map changed some defaults that an earlier run may have saved, so
// set them once (this number goes up when the map changes defaults again)
convar_t* frameMap = Cvar_Get( "vr_frame_map", "0", CVAR_ARCHIVE, "Version of the Steam Frame controller map the saved settings were made for" );
if (frameMap->integer < 1) {
Cvar_SetFloat( "vr_quick_crouchjump", 0.0f );
Cvar_SetFloat( "vr_gesture_triggered_use", 1.0f );
Cvar_SetFloat( "vr_frame_map", 1.0f );
}
}
vr_hud_distance = Cvar_Get( "vr_hud_distance", "1.0", CVAR_ARCHIVE, "How far ahead the HUD sits, in metres" );
vr_refresh_rate = Cvar_Get( "vr_refresh_rate", "90", CVAR_ARCHIVE, "The display refresh rate to ask for, the highest the runtime offers that isn't over this" );
vr_resolution_scale = Cvar_Get( "vr_resolution_scale", "1.25", CVAR_ARCHIVE, "Eye image size as a multiple of the runtime's recommended one (1.25 is the panel's 2160 on the Steam Frame), 0.5 to 2" );
vr_flat_screen = Cvar_Get( "vr_flat_screen", "0", CVAR_ARCHIVE, "1 shows the game on a flat screen in front of you instead of around you. The controllers only point and click then, like in a menu" );
vr_hud_level = Cvar_Get( "vr_hud_level", "1", CVAR_ARCHIVE, "1 keeps the HUD, text, and camera-facing sprites level when the head rolls, 0 lets them roll with the head" );
#endif
//Not to be changed by users, as it will be overwritten anyway
vr_stereo_side = Cvar_Get( "vr_stereo_side", "0", CVAR_READ_ONLY, "Eye being drawn" );
@@ -530,18 +671,29 @@ void * AppThreadFunction( void * parm )
TBXR_InitialiseOpenXR();
#ifdef L1VR_STEAM_FRAME
VrDiag_Init();
#endif
Host_Main(argc, (const char**)argv, "valve", false, NULL);
VR_Init();
TBXR_EnterVR();
#ifdef L1VR_STEAM_FRAME
{
// the eye image size, from vr_resolution_scale (the engine set up its screen before it was known)
extern void R_ChangeDisplaySettingsFast( int width, int height );
TBXR_SetEyeScale( vr_resolution_scale->value );
R_ChangeDisplaySettingsFast( (int)gAppState.Width, (int)gAppState.Height );
}
#endif
TBXR_InitRenderer();
TBXR_InitActions();
TBXR_WaitForSessionActive();
//Always use this folder
chdir("/sdcard/xash");
chdir(L1VR_DATA_DIR);
bool destroyed = false;
while (!destroyed)
@@ -555,6 +707,32 @@ void * AppThreadFunction( void * parm )
{
TBXR_FrameSetup();
#ifdef L1VR_STEAM_FRAME
{
//The positional movement scaling goes by vr_refresh. SteamVR picks the rate for
//the app, so take it from the frame period rather than from what's on offer.
static int lastRate = 0;
const XrDuration period = gAppState.FrameState.predictedDisplayPeriod;
const int rate = period > 0 ? (int)(1e9 / (double)period + 0.5) : 0;
if (rate > 0 && rate != lastRate)
{
char text[16];
Q_snprintf(text, sizeof(text), "%d", rate);
Cvar_Set2("vr_refresh", text, true);
ALOGI("[openxr] frame period %.2f ms, vr_refresh %s", period / 1e6, text);
lastRate = rate;
}
}
VrDiag_FrameBegin();
if (!TBXR_ShouldRender())
{
//Standby: keep the frame loop going, but the game waits too
TBXR_submitFrame();
VrDiag_FrameEnd();
continue;
}
#endif
//Call the game drawing code to populate the cylinder layer texture
//if we are now shutting down, drop out here
if (isHostAlive())
@@ -570,7 +748,11 @@ void * AppThreadFunction( void * parm )
{
TBXR_prepareEyeBuffer(eye);
#ifdef L1VR_STEAM_FRAME
if (TBXR_ShouldRender())
#else
if (gAppState.FrameState.shouldRender)
#endif
{
if (isScopeEngaged())
{
@@ -676,12 +858,18 @@ void * AppThreadFunction( void * parm )
GL( glDisable( GL_SCISSOR_TEST ) );
}
#ifdef L1VR_STEAM_FRAME
VrDiag_AfterEye(eye);
#endif
TBXR_finishEyeBuffer(eye);
}
Host_EndFrame();
TBXR_submitFrame();
#ifdef L1VR_STEAM_FRAME
VrDiag_FrameEnd();
#endif
}
}
}
@@ -717,6 +905,8 @@ static jobject jniCallbackObj=0;
int JNI_OnLoad(JavaVM* vm, void* reserved)
{
JNIEnv *env;
//jni_shutdown needs this, nothing else sets it
jVM = vm;
if((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4) != JNI_OK)
{
ALOGE("Failed JNI_OnLoad");
@@ -1,5 +1,13 @@
#include <string.h>
#include "VrCommon.h"
#ifdef L1VR_STEAM_FRAME
#include "VrCvars.h"
#include "VrInputFrame.h"
#include "VrDiag.h"
#endif
extern ovrApp gAppState;
XrResult CheckXrResult(XrResult res, const char* originator) {
@@ -45,6 +53,14 @@ XrAction BTouchAction = 0;
XrAction XTouchAction = 0;
XrAction YTouchAction = 0;
XrAction aimAction = 0;
#ifdef L1VR_STEAM_FRAME
XrAction viewAction = 0;
XrAction bumperAction = 0;
XrAction dpadUpAction = 0;
XrAction dpadDownAction = 0;
XrAction dpadLeftAction = 0;
XrAction dpadRightAction = 0;
#endif
XrSpace aimSpace[SIDE_COUNT];
XrPath handSubactionPath[SIDE_COUNT];
@@ -128,6 +144,122 @@ void CreateAction(
OXR(xrCreateAction(actionSet, &aci, action));
}
#ifdef L1VR_STEAM_FRAME
/*
================================================================================
Steam Frame controllers
The Frame's controllers are a split gamepad: the left one has the D-pad, View,
bumper, trigger, grip and stick, the right one has A B X Y, Menu, bumper, trigger,
grip and stick. What each button does is in VrFrameMap.c (and the table in
docs/STEAM_FRAME_VR.md); this file reads them, VrInputFrame.c applies the map.
Every path has to exist on the profile it's suggested for, or the runtime refuses
the whole profile (the table check is tools/steam_frame/tests/check_bindings.py
in the dev branch).
================================================================================
*/
#define FRAME_PROFILE "/interaction_profiles/valve/frame_controller_valve"
#define INDEX_PROFILE "/interaction_profiles/valve/index_controller"
typedef struct {
XrAction* action;
const char* path;
} profileBinding_t;
static const profileBinding_t frameBindings[] = {
{ &touchpadAction, "/user/hand/left/input/thumbstick/click" },
{ &touchpadAction, "/user/hand/right/input/thumbstick/click" },
{ &joystickAction, "/user/hand/left/input/thumbstick" },
{ &joystickAction, "/user/hand/right/input/thumbstick" },
{ &thumbstickTouchAction, "/user/hand/left/input/thumbstick/touch" },
{ &thumbstickTouchAction, "/user/hand/right/input/thumbstick/touch" },
{ &triggerAction, "/user/hand/left/input/trigger/value" },
{ &triggerAction, "/user/hand/right/input/trigger/value" },
{ &GripAction, "/user/hand/left/input/squeeze/value" },
{ &GripAction, "/user/hand/right/input/squeeze/value" },
{ &poseAction, "/user/hand/left/input/grip/pose" },
{ &poseAction, "/user/hand/right/input/grip/pose" },
{ &aimAction, "/user/hand/left/input/aim/pose" },
{ &aimAction, "/user/hand/right/input/aim/pose" },
{ &vibrateAction, "/user/hand/left/output/haptic" },
{ &vibrateAction, "/user/hand/right/output/haptic" },
{ &AAction, "/user/hand/right/input/a/click" },
{ &BAction, "/user/hand/right/input/b/click" },
{ &XAction, "/user/hand/right/input/x/click" },
{ &YAction, "/user/hand/right/input/y/click" },
{ &backAction, "/user/hand/right/input/menu/click" },
{ &viewAction, "/user/hand/left/input/view/click" },
{ &bumperAction, "/user/hand/left/input/bumper/click" },
{ &bumperAction, "/user/hand/right/input/bumper/click" },
{ &dpadUpAction, "/user/hand/left/input/dpad_up/click" },
{ &dpadDownAction, "/user/hand/left/input/dpad_down/click" },
{ &dpadLeftAction, "/user/hand/left/input/dpad_left/click" },
{ &dpadRightAction, "/user/hand/left/input/dpad_right/click" },
};
//Index controllers on SteamVR: A and B on both hands, like the Touch ones. They
//have no Menu button for apps, so the pause is the left trackpad pressed hard.
static const profileBinding_t indexBindings[] = {
{ &touchpadAction, "/user/hand/left/input/thumbstick/click" },
{ &touchpadAction, "/user/hand/right/input/thumbstick/click" },
{ &joystickAction, "/user/hand/left/input/thumbstick" },
{ &joystickAction, "/user/hand/right/input/thumbstick" },
{ &thumbstickTouchAction, "/user/hand/left/input/thumbstick/touch" },
{ &thumbstickTouchAction, "/user/hand/right/input/thumbstick/touch" },
{ &triggerAction, "/user/hand/left/input/trigger/value" },
{ &triggerAction, "/user/hand/right/input/trigger/value" },
{ &TriggerTouchAction, "/user/hand/left/input/trigger/touch" },
{ &TriggerTouchAction, "/user/hand/right/input/trigger/touch" },
{ &GripAction, "/user/hand/left/input/squeeze/value" },
{ &GripAction, "/user/hand/right/input/squeeze/value" },
{ &poseAction, "/user/hand/left/input/grip/pose" },
{ &poseAction, "/user/hand/right/input/grip/pose" },
{ &aimAction, "/user/hand/left/input/aim/pose" },
{ &aimAction, "/user/hand/right/input/aim/pose" },
{ &vibrateAction, "/user/hand/left/output/haptic" },
{ &vibrateAction, "/user/hand/right/output/haptic" },
{ &XAction, "/user/hand/left/input/a/click" },
{ &YAction, "/user/hand/left/input/b/click" },
{ &AAction, "/user/hand/right/input/a/click" },
{ &BAction, "/user/hand/right/input/b/click" },
{ &XTouchAction, "/user/hand/left/input/a/touch" },
{ &YTouchAction, "/user/hand/left/input/b/touch" },
{ &ATouchAction, "/user/hand/right/input/a/touch" },
{ &BTouchAction, "/user/hand/right/input/b/touch" },
{ &backAction, "/user/hand/left/input/trackpad/force" },
};
static XrResult TBXR_SuggestBindings(const char* profile, const profileBinding_t* table, int count)
{
XrPath profilePath;
CHECK_XRCMD(xrStringToPath(gAppState.Instance, profile, &profilePath));
XrActionSuggestedBinding bindings[128];
for (int i = 0; i < count; i++) {
XrPath path;
CHECK_XRCMD(xrStringToPath(gAppState.Instance, table[i].path, &path));
bindings[i] = ActionSuggestedBinding(*table[i].action, path);
}
XrInteractionProfileSuggestedBinding suggestedBindings = {};
suggestedBindings.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING;
suggestedBindings.interactionProfile = profilePath;
suggestedBindings.suggestedBindings = bindings;
suggestedBindings.countSuggestedBindings = count;
suggestedBindings.next = NULL;
XrResult result = xrSuggestInteractionProfileBindings(gAppState.Instance, &suggestedBindings);
char resultString[XR_MAX_RESULT_STRING_SIZE];
xrResultToString(gAppState.Instance, result, resultString);
ALOGI("[openxr] suggested %d bindings for %s: %s", count, profile, resultString);
return result;
}
#endif
void TBXR_InitActions( void )
{
// Create an action set.
@@ -182,6 +314,14 @@ void TBXR_InitActions( void )
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "btouch", "Btouch", SIDE_COUNT, handSubactionPath, &BTouchAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "xtouch", "Xtouch", SIDE_COUNT, handSubactionPath, &XTouchAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "ytouch", "Ytouch", SIDE_COUNT, handSubactionPath, &YTouchAction);
#ifdef L1VR_STEAM_FRAME
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "viewkey", "Viewkey", SIDE_COUNT, handSubactionPath, &viewAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "bumperkey", "Bumperkey", SIDE_COUNT, handSubactionPath, &bumperAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "dpadup", "Dpadup", SIDE_COUNT, handSubactionPath, &dpadUpAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "dpaddown", "Dpaddown", SIDE_COUNT, handSubactionPath, &dpadDownAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "dpadleft", "Dpadleft", SIDE_COUNT, handSubactionPath, &dpadLeftAction);
CreateAction(actionSet, XR_ACTION_TYPE_BOOLEAN_INPUT, "dpadright", "Dpadright", SIDE_COUNT, handSubactionPath, &dpadRightAction);
#endif
}
XrPath selectPath[SIDE_COUNT];
@@ -276,6 +416,17 @@ void TBXR_InitActions( void )
XrResult result;
#ifdef L1VR_STEAM_FRAME
//SteamVR picks whichever profile matches the controllers, so suggest all of
//them: the Frame's own first, then Index, and Touch below
if (TBXR_ExtensionEnabled(XR_VALVE_FRAME_CONTROLLER_INTERACTION_EXTENSION_NAME)) {
TBXR_SuggestBindings(FRAME_PROFILE, frameBindings, sizeof(frameBindings) / sizeof(frameBindings[0]));
} else {
ALOGI("[openxr] the runtime doesn't offer the Frame controller extension, no suggestion for %s", FRAME_PROFILE);
}
TBXR_SuggestBindings(INDEX_PROFILE, indexBindings, sizeof(indexBindings) / sizeof(indexBindings[0]));
result = XR_ERROR_PATH_UNSUPPORTED;
#else
//First try Pico Devices
{
XrPath picoMixedRealityInteractionProfilePath;
@@ -334,6 +485,7 @@ void TBXR_InitActions( void )
suggestedBindings.next = NULL;
result = xrSuggestInteractionProfileBindings(gAppState.Instance, &suggestedBindings);
}
#endif
if (result != XR_SUCCESS)
{
@@ -385,6 +537,13 @@ void TBXR_InitActions( void )
suggestedBindings.countSuggestedBindings = currBinding;
suggestedBindings.next = NULL;
result = xrSuggestInteractionProfileBindings(gAppState.Instance, &suggestedBindings);
#ifdef L1VR_STEAM_FRAME
{
char resultString[XR_MAX_RESULT_STRING_SIZE];
xrResultToString(gAppState.Instance, result, resultString);
ALOGI("[openxr] suggested %d bindings for /interaction_profiles/oculus/touch_controller: %s", currBinding, resultString);
}
#endif
}
XrActionSpaceCreateInfo actionSpaceInfo = {};
@@ -427,6 +586,81 @@ void TBXR_SyncActions( void )
}
}
#ifdef L1VR_STEAM_FRAME
static bool usingFrameProfile = false;
//Which profile the runtime is using for the controllers right now, and does it
//have our Frame layout
static void TBXR_UpdateInteractionProfile()
{
static char lastProfile[SIDE_COUNT][XR_MAX_PATH_LENGTH];
bool frame = false;
for (int hand = 0; hand < SIDE_COUNT; hand++) {
XrInteractionProfileState state = {};
state.type = XR_TYPE_INTERACTION_PROFILE_STATE;
char profile[XR_MAX_PATH_LENGTH] = "";
if (XR_SUCCEEDED(xrGetCurrentInteractionProfile(gAppState.Session, handSubactionPath[hand], &state)) &&
state.interactionProfile != XR_NULL_PATH) {
uint32_t length = 0;
xrPathToString(gAppState.Instance, state.interactionProfile, sizeof(profile), &length, profile);
}
if (strcmp(profile, lastProfile[hand]) != 0) {
strcpy(lastProfile[hand], profile);
ALOGI("[openxr] %s controller profile: %s", hand == SIDE_LEFT ? "left" : "right",
profile[0] ? profile : "(none yet)");
}
frame |= (strcmp(profile, FRAME_PROFILE) == 0);
}
usingFrameProfile = frame;
}
//What the physical buttons say this frame
static void TBXR_ReadFrameInputs(frameRaw_t* raw)
{
raw->triggerLeft = GetActionStateFloat(triggerAction, SIDE_LEFT).currentState;
raw->triggerRight = GetActionStateFloat(triggerAction, SIDE_RIGHT).currentState;
raw->gripLeft = GetActionStateFloat(GripAction, SIDE_LEFT).currentState;
raw->gripRight = GetActionStateFloat(GripAction, SIDE_RIGHT).currentState;
raw->bumperLeft = GetActionStateBoolean(bumperAction, SIDE_LEFT).currentState != 0;
raw->bumperRight = GetActionStateBoolean(bumperAction, SIDE_RIGHT).currentState != 0;
raw->stickClickLeft = GetActionStateBoolean(touchpadAction, SIDE_LEFT).currentState != 0;
raw->stickClickRight = GetActionStateBoolean(touchpadAction, SIDE_RIGHT).currentState != 0;
raw->a = GetActionStateBoolean(AAction, SIDE_RIGHT).currentState != 0;
raw->b = GetActionStateBoolean(BAction, SIDE_RIGHT).currentState != 0;
raw->x = GetActionStateBoolean(XAction, SIDE_RIGHT).currentState != 0;
raw->y = GetActionStateBoolean(YAction, SIDE_RIGHT).currentState != 0;
raw->menu = GetActionStateBoolean(backAction, SIDE_RIGHT).currentState != 0;
raw->view = GetActionStateBoolean(viewAction, SIDE_LEFT).currentState != 0;
raw->dpadUp = GetActionStateBoolean(dpadUpAction, SIDE_LEFT).currentState != 0;
raw->dpadDown = GetActionStateBoolean(dpadDownAction, SIDE_LEFT).currentState != 0;
raw->dpadLeft = GetActionStateBoolean(dpadLeftAction, SIDE_LEFT).currentState != 0;
raw->dpadRight = GetActionStateBoolean(dpadRightAction, SIDE_LEFT).currentState != 0;
const XrActionStateVector2f left = GetActionStateVector2(joystickAction, SIDE_LEFT);
const XrActionStateVector2f right = GetActionStateVector2(joystickAction, SIDE_RIGHT);
raw->stickLeftX = left.currentState.x;
raw->stickLeftY = left.currentState.y;
raw->stickRightX = right.currentState.x;
raw->stickRightY = right.currentState.y;
}
static void TBXR_UpdateFrameLayout()
{
TBXR_UpdateInteractionProfile();
// (with diag_press the map runs without controllers, to be tested)
if (!usingFrameProfile && !VrDiag_Injecting()) {
return;
}
frameRaw_t raw;
TBXR_ReadFrameInputs(&raw);
VrDiag_InjectInputs(&raw);
VrInputFrame_Apply(&raw);
}
#endif
void TBXR_UpdateControllers( )
{
TBXR_SyncActions();
@@ -446,6 +680,9 @@ void TBXR_UpdateControllers( )
gAppState.TrackedController[i].Active = (loc.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0;
gAppState.TrackedController[i].Pose = loc.pose;
gAppState.TrackedController[i].Velocity = vel;
#ifdef L1VR_STEAM_FRAME
VrDiag_SyntheticHand(i, &gAppState.TrackedController[i]);
#endif
}
leftRemoteTracking_new = gAppState.TrackedController[0];
@@ -498,6 +735,10 @@ void TBXR_UpdateControllers( )
moveJoystickState = GetActionStateVector2(joystickAction, SIDE_RIGHT);
rightTrackedRemoteState_new.Joystick.x = moveJoystickState.currentState.x;
rightTrackedRemoteState_new.Joystick.y = moveJoystickState.currentState.y;
#ifdef L1VR_STEAM_FRAME
TBXR_UpdateFrameLayout();
#endif
}
File diff suppressed because it is too large. Load diff
@@ -30,13 +30,20 @@
#define ALOGE(...) __android_log_print( ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__ )
#define ALOGI(...) __android_log_print( ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__ )
#if DEBUG
#define ALOGV(...) __android_log_print( ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__ )
#else
#define ALOGV(...)
#endif
#ifdef L1VR_STEAM_FRAME
// the projection or menu layer, then the menu laser's beam and its dot
enum { ovrMaxLayerCount = 3 };
#else
enum { ovrMaxLayerCount = 1 };
#endif
enum { ovrMaxNumEyes = 2 };
typedef enum xrButton_ {
@@ -354,4 +361,23 @@ void TBXR_prepareEyeBuffer(int eye );
void TBXR_finishEyeBuffer(int eye );
void TBXR_submitFrame();
#ifdef L1VR_STEAM_FRAME
//Steam Frame only
#define XR_VALVE_FRAME_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_VALVE_frame_controller_interaction"
bool TBXR_ExtensionEnabled(const char* name);
bool TBXR_ShouldRender();
void TBXR_RecenterToHead(bool resetHeight);
// The menu laser: where the weapon hand's aim ray meets the flat screen, 0..1 across and down. False
// when the screen isn't showing or the hand isn't tracked (the ray can also miss the panel's edge).
bool TBXR_ScreenPointer(float* u, float* v);
// Dev: the pointer layers' poses, one line each, for the dump
void TBXR_DescribePointerLayers(FILE* file);
// The eye image size from the recommended one times a scale, held to the limits. Before the swapchains are made.
float TBXR_SetEyeScale(float scale);
#endif
#endif //vrcommon_h
+9 -1
View File
@@ -33,4 +33,12 @@ extern convar_t *vr_gesture_triggered_use;
extern convar_t *vr_use_gesture_boundary;
//Used and updated continuously during rendering
extern convar_t *vr_stereo_side;
extern convar_t *vr_stereo_side;
#ifdef L1VR_STEAM_FRAME
extern convar_t *vr_hud_distance;
extern convar_t *vr_hud_level;
extern convar_t *vr_refresh_rate;
extern convar_t *vr_resolution_scale;
extern convar_t *vr_flat_screen;
#endif
+578
View File
@@ -0,0 +1,578 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <math.h>
#include <time.h>
#include <common/common.h>
#include <client/client.h>
#include "VrDiag.h"
#include "VrDiagCore.h"
#include "VrEyeMath.h"
#include "VrFrameMap.h"
#include "VrCommon.h"
extern convar_t *vr_worldscale;
extern ovrApp gAppState;
extern float snapTurn;
void TBXR_exit(int exitCode);
#define DIAG_DIR "/sdcard/Documents/Lambda1VR"
#define DIAG_FILE DIAG_DIR "/vr-diag.ini"
#define DUMP_DIR DIAG_DIR "/vr-dumps"
static vrDiagConfig_t config;
static bool enabled = false;
static long frames = 0; // XR frames since the loop started
static long renderedFrames = 0; // the ones the game was drawn for
static bool mapStarted = false;
static long mapStartFrame = 0;
static bool cmdDone = false;
static bool dumpDone = false;
static bool dumpEyeDone[2];
// perf window
static double windowStart = 0.0;
static double lastBegin = 0.0;
static double cpuSum = 0.0;
static double waitSum = 0.0, acquireSum = 0.0; // ms in xrWaitFrame and xrAcquireSwapchainImage (+ its wait)
static double threadStart = 0.0, threadCpuNow = 0.0; // the game thread's CPU time
static long windowFrames = 0;
static long windowMissed = 0;
void VrDiag_NoteTiming(int what, double ms)
{
if (what == VRDIAG_TIME_WAIT_FRAME)
waitSum += ms;
else
acquireSum += ms;
}
static double threadCpuMs(void)
{
struct timespec now;
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now);
return now.tv_sec * 1000.0 + now.tv_nsec / 1e6;
}
void VrDiag_Init(void)
{
VrDiagCore_Defaults(&config);
FILE* file = fopen(DIAG_FILE, "rb");
if (file == NULL)
{
return;
}
char text[4096];
size_t length = fread(text, 1, sizeof(text) - 1, file);
fclose(file);
text[length] = '\0';
if (VrDiagCore_Parse(text, &config) == 0)
{
return;
}
enabled = true;
ALOGI("[vr-diag] %s: force_render=%d diag_yaw=%.1f autostart=%d map=%s dump_frame=%d exit_after=%d perf=%d probe=%d oneshot=%d cmd=%s diag_roll=%.1f diag_gap=%.2f presses=%d",
DIAG_FILE, config.forceRender, config.yaw, config.autostart, config.map[0] ? config.map : "-",
config.dumpFrame, config.exitAfter, config.perf, config.probe, config.oneshot, config.cmd[0] ? config.cmd : "-", config.roll, config.gap, config.pressCount);
if (config.oneshot)
{
rename(DIAG_FILE, DIAG_FILE ".last");
ALOGI("[vr-diag] one-shot: the file is now vr-diag.ini.last");
}
}
bool VrDiag_ForceRender(void)
{
return enabled && config.forceRender;
}
bool VrDiag_Injecting(void)
{
return enabled && config.pressCount > 0;
}
bool VrDiag_InjectInputs(frameRaw_t* raw)
{
if (!VrDiag_Injecting() || !mapStarted)
{
return false;
}
bool any = false;
for (int i = 0; i < config.pressCount; i++)
{
const vrDiagPress_t* press = &config.presses[i];
const long first = mapStartFrame + press->start;
if (renderedFrames < first || renderedFrames >= first + press->length)
{
continue;
}
any = true;
switch (press->input)
{
case FI_L_TRIGGER: raw->triggerLeft = 1.0f; break;
case FI_R_TRIGGER: raw->triggerRight = 1.0f; break;
case FI_L_GRIP: raw->gripLeft = 1.0f; break;
case FI_R_GRIP: raw->gripRight = 1.0f; break;
case FI_L_BUMPER: raw->bumperLeft = true; break;
case FI_R_BUMPER: raw->bumperRight = true; break;
case FI_L_STICK_CLICK: raw->stickClickLeft = true; break;
case FI_R_STICK_CLICK: raw->stickClickRight = true; break;
case FI_A: raw->a = true; break;
case FI_B: raw->b = true; break;
case FI_X: raw->x = true; break;
case FI_Y: raw->y = true; break;
case FI_MENU_SHORT: case FI_MENU_HOLD: raw->menu = true; break;
case FI_VIEW_SHORT: case FI_VIEW_HOLD_1S: case FI_VIEW_HOLD_3S: raw->view = true; break;
case FI_DPAD_UP: raw->dpadUp = true; break;
case FI_DPAD_DOWN: raw->dpadDown = true; break;
case FI_DPAD_LEFT: raw->dpadLeft = true; break;
case FI_DPAD_RIGHT: raw->dpadRight = true; break;
case FI_L_STICK_UP: raw->stickLeftY = 0.9f; break;
case FI_L_STICK_DOWN: raw->stickLeftY = -0.9f; break;
case FI_L_STICK_LEFT: raw->stickLeftX = -0.9f; break;
case FI_L_STICK_RIGHT: raw->stickLeftX = 0.9f; break;
case FI_R_STICK_UP: raw->stickRightY = 0.9f; break;
case FI_R_STICK_DOWN: raw->stickRightY = -0.9f; break;
case FI_R_STICK_LEFT: raw->stickRightX = -0.9f; break;
case FI_R_STICK_RIGHT: raw->stickRightX = 0.9f; break;
default: break;
}
}
return any;
}
static XrQuaternionf yawQuat(float degrees)
{
XrQuaternionf q = {0.0f, sinf(DEG2RAD(degrees) * 0.5f), 0.0f, cosf(DEG2RAD(degrees) * 0.5f)};
return q;
}
bool VrDiag_SyntheticHead(XrPosef* pose)
{
if (!VrDiag_ForceRender())
{
return false;
}
memset(pose, 0, sizeof(*pose));
// yaw about up, then roll about the view axis (z)
const XrQuaternionf y = yawQuat(config.yaw);
const float half = DEG2RAD(config.roll) * 0.5f;
const XrQuaternionf r = {0.0f, 0.0f, sinf(half), cosf(half)};
pose->orientation.x = y.w * r.x + y.x * r.w + y.y * r.z - y.z * r.y;
pose->orientation.y = y.w * r.y - y.x * r.z + y.y * r.w + y.z * r.x;
pose->orientation.z = y.w * r.z + y.x * r.y - y.y * r.x + y.z * r.w;
pose->orientation.w = y.w * r.w - y.x * r.x - y.y * r.y - y.z * r.z;
pose->position.y = 1.6f; // standing height above the floor
return true;
}
bool VrDiag_SyntheticViews(XrView* views)
{
if (!VrDiag_ForceRender())
{
return false;
}
// The Frame's usual asymmetric field of view, when the runtime gave none
// left eye L -1.028 R 0.885 U 0.864 D -1.048, the right eye is its mirror
for (int eye = 0; eye < ovrMaxNumEyes; eye++)
{
XrFovf* fov = &views[eye].fov;
if (fabsf(fov->angleLeft) + fabsf(fov->angleRight) < 0.1f)
{
fov->angleLeft = (eye == 0) ? -1.028f : -0.885f;
fov->angleRight = (eye == 0) ? 0.885f : 1.028f;
fov->angleUp = 0.864f;
fov->angleDown = -1.048f;
}
views[eye].pose.orientation.w = 1.0f;
views[eye].pose.position.x = (eye == 0) ? -0.032f : 0.032f; // 64 mm
}
return true;
}
bool VrDiag_SyntheticHand(int hand, ovrTrackedController* controller)
{
if (!VrDiag_ForceRender())
{
return false;
}
// Parked in front of the chest, turned with the head
const float side = (hand == 0) ? -config.gap * 0.5f : config.gap * 0.5f;
const float rad = DEG2RAD(config.yaw);
const float x = side;
const float z = -0.3f;
controller->Active = GL_TRUE;
memset(&controller->Pose, 0, sizeof(controller->Pose));
{
// aimed where the head looks, or turned from it: yaw about up, then pitch about the hand's own x
const XrQuaternionf y = yawQuat(config.yaw + config.handYaw);
const float half = DEG2RAD(config.handPitch) * 0.5f;
const XrQuaternionf p = {sinf(half), 0.0f, 0.0f, cosf(half)};
controller->Pose.orientation.x = y.w * p.x + y.x * p.w + y.y * p.z - y.z * p.y;
controller->Pose.orientation.y = y.w * p.y - y.x * p.z + y.y * p.w + y.z * p.x;
controller->Pose.orientation.z = y.w * p.z + y.x * p.y - y.y * p.x + y.z * p.w;
controller->Pose.orientation.w = y.w * p.w - y.x * p.x - y.y * p.y - y.z * p.z;
}
controller->Pose.position.x = x * cosf(rad) + z * sinf(rad);
controller->Pose.position.y = 1.2f;
controller->Pose.position.z = -x * sinf(rad) + z * cosf(rad);
memset(&controller->Velocity, 0, sizeof(controller->Velocity));
controller->Velocity.type = XR_TYPE_SPACE_VELOCITY;
return true;
}
static const char* connectionName(void)
{
switch (cls.state)
{
case ca_uninitialized: return "uninitialized";
case ca_disconnected: return "disconnected";
case ca_connecting: return "connecting";
case ca_connected: return "connected";
case ca_active: return "active";
case ca_cinematic: return "cinematic";
default: return "unknown";
}
}
// Ground truth for the stereo check: rays from the left eye through a grid of pixels, traced
// into the world, so each gets the depth along the view axis and the disparity a correct
// stereo pair should show there (the eye separation times the focal length over the depth).
static void dumpDepthProbes(FILE* file)
{
if (gAppState.Projections == NULL || cls.state != ca_active)
{
return;
}
const XrView* v0 = &gAppState.Projections[0];
const XrView* v1 = &gAppState.Projections[1];
const int width = gAppState.Renderer.FrameBuffer[0].Width;
const int height = gAppState.Renderer.FrameBuffer[0].Height;
const float fx = VrEye_FocalPixels(v0->fov.angleLeft, v0->fov.angleRight, width);
const float fy = (float)height / (tanf(v0->fov.angleUp) - tanf(v0->fov.angleDown));
const float axisX = VrEye_AxisPixel(v0->fov.angleLeft, v0->fov.angleRight, width);
const float axisY = tanf(v0->fov.angleUp) / (tanf(v0->fov.angleUp) - tanf(v0->fov.angleDown)) * (float)height;
const float ipd = v1->pose.position.x - v0->pose.position.x;
vec3_t forward, right, up;
AngleVectors(cl.refdef.viewangles, forward, right, up);
// the left eye: the middle of the head, moved by the eye's offset in the head's frame
const float position[3] = {v0->pose.position.x, v0->pose.position.y, v0->pose.position.z};
float offset[3];
VrEye_ToEngineOffset(position, vr_worldscale->value, offset);
vec3_t eye;
for (int i = 0; i < 3; i++)
{
eye[i] = cl.refdef.vieworg[i] + forward[i] * offset[0] - right[i] * offset[1] + up[i] * offset[2];
}
fprintf(file, "probe_setup units_per_m %.2f ipd_m %.5f focal_px %.2f axis_px %.2f %.2f eye0_world %.2f %.2f %.2f view_angles %.2f %.2f %.2f\n",
vr_worldscale->value, ipd, fx, axisX, axisY, eye[0], eye[1], eye[2],
cl.refdef.viewangles[0], cl.refdef.viewangles[1], cl.refdef.viewangles[2]);
for (float fv = 0.20f; fv < 0.97f; fv += 0.08f)
{
for (float fu = 0.15f; fu < 0.86f; fu += 0.1f)
{
const float u = fu * width;
const float v = fv * height;
const float left = -(u - axisX) / fx; // to the left of the view axis, per unit forward
const float upward = (axisY - v) / fy;
vec3_t dir, end;
for (int i = 0; i < 3; i++)
{
dir[i] = forward[i] - right[i] * left + up[i] * upward;
end[i] = eye[i] + dir[i] * 8192.0f;
}
pmtrace_t trace = CL_TraceLine(eye, end, PM_WORLD_ONLY);
if (trace.fraction >= 1.0f || trace.allsolid)
{
continue;
}
vec3_t hit;
for (int i = 0; i < 3; i++)
{
hit[i] = eye[i] + (end[i] - eye[i]) * trace.fraction;
}
const float depthUnits = (hit[0] - eye[0]) * forward[0] + (hit[1] - eye[1]) * forward[1] + (hit[2] - eye[2]) * forward[2];
const float depthM = depthUnits / vr_worldscale->value;
if (depthM < 0.25f)
{
continue;
}
fprintf(file, "probe %.0f %.0f depth_m %.3f disparity_px %.2f\n", u, v, depthM, ipd * fx / depthM);
}
}
}
static void dumpState(void)
{
char path[256];
snprintf(path, sizeof(path), DUMP_DIR "/vr-state.txt");
FILE* file = fopen(path, "w");
if (file == NULL)
{
return;
}
fprintf(file, "xr_frame %ld\nrendered_frame %ld\nshould_render %d\nforced %d\n", frames, renderedFrames,
(int)gAppState.FrameState.shouldRender, VrDiag_ForceRender() ? 1 : 0);
fprintf(file, "eye_size %dx%d\n", gAppState.Renderer.FrameBuffer[0].Width, gAppState.Renderer.FrameBuffer[0].Height);
fprintf(file, "head_position %.3f %.3f %.3f\n", hmdPosition[0], hmdPosition[1], hmdPosition[2]);
fprintf(file, "head_angles pitch %.2f yaw %.2f roll %.2f\n", hmdorientation[PITCH], hmdorientation[YAW], hmdorientation[ROLL]);
fprintf(file, "player_yaw %.2f\nsnap_turn %.2f\ndiag_yaw %.2f\ndiag_roll %.2f\nview_roll %.2f\nvr_fov_x %d\n", playerYaw, snapTurn,
config.yaw, config.roll, cl.refdef.viewangles[ROLL], vrFOV);
if (gAppState.Projections != NULL)
{
for (int eye = 0; eye < ovrMaxNumEyes; eye++)
{
const XrFovf* fov = &gAppState.Projections[eye].fov;
fprintf(file, "fov_eye%d L %.3f R %.3f U %.3f D %.3f\n", eye, fov->angleLeft, fov->angleRight, fov->angleUp, fov->angleDown);
const XrPosef* pose = &gAppState.Projections[eye].pose;
fprintf(file, "eye%d_in_head_mm %.2f %.2f %.2f quat %.5f %.5f %.5f %.5f\n", eye, pose->position.x * 1000.0f,
pose->position.y * 1000.0f, pose->position.z * 1000.0f, pose->orientation.x, pose->orientation.y,
pose->orientation.z, pose->orientation.w);
}
}
dumpDepthProbes(file);
{
float pu = 0.0f, pv = 0.0f;
const bool aimed = TBXR_ScreenPointer(&pu, &pv);
fprintf(file, "pointer %s u %.4f v %.4f (the cursor at %.0f, %.0f in the screen image)\n", aimed ? "aimed" : "none", pu, pv,
pu * gAppState.Renderer.FrameBuffer[0].Width, pv * gAppState.Renderer.FrameBuffer[0].Height);
}
TBXR_DescribePointerLayers(file);
fprintf(file, "screen_layer %d\nconnection %s\nkey_dest %d\nmaxclients %d\n", VR_UseScreenLayer() ? 1 : 0,
connectionName(), (int)cls.key_dest, (int)cl.maxclients);
fclose(file);
}
// The driver's GL for the readback: the plain gl* names in this library are gl4es's,
// which doesn't know the swapchain's framebuffers
typedef void (GL_APIENTRYP PFN_BindFramebuffer)(GLenum target, GLuint framebuffer);
typedef void (GL_APIENTRYP PFN_ReadPixels)(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels);
typedef GLenum (GL_APIENTRYP PFN_GetError)(void);
typedef void (GL_APIENTRYP PFN_Finish)(void);
typedef void (GL_APIENTRYP PFN_GetIntegerv)(GLenum pname, GLint* data);
typedef GLboolean (GL_APIENTRYP PFN_IsEnabled)(GLenum cap);
typedef void (GL_APIENTRYP PFN_ClearColor)(GLfloat r, GLfloat g, GLfloat b, GLfloat a);
typedef void (GL_APIENTRYP PFN_Clear)(GLbitfield mask);
typedef void (GL_APIENTRYP PFN_Disable)(GLenum cap);
// One synchronous readback on the chosen frame, it's a test dump
void VrDiag_AfterEye(int eye)
{
if (!enabled || config.dumpFrame <= 0 || dumpDone || renderedFrames < config.dumpFrame || eye < 0 || eye > 1 || dumpEyeDone[eye])
{
return;
}
ovrFramebuffer* frameBuffer = &gAppState.Renderer.FrameBuffer[eye];
const int width = frameBuffer->Width;
const int height = frameBuffer->Height;
unsigned char* pixels = (unsigned char*)malloc((size_t)width * (size_t)height * 4);
if (pixels == NULL)
{
return;
}
mkdir(DUMP_DIR, 0777);
PFN_BindFramebuffer driverBind = (PFN_BindFramebuffer)eglGetProcAddress("glBindFramebuffer");
PFN_ReadPixels driverRead = (PFN_ReadPixels)eglGetProcAddress("glReadPixels");
PFN_GetError driverError = (PFN_GetError)eglGetProcAddress("glGetError");
PFN_Finish driverFinish = (PFN_Finish)eglGetProcAddress("glFinish");
if (driverBind == NULL || driverRead == NULL || driverError == NULL || driverFinish == NULL)
{
free(pixels);
dumpDone = true;
ALOGE("[vr-dump] the driver's GL functions weren't found");
return;
}
glFinish(); // gl4es flushes what it holds back, and finishes
const GLenum drawError = driverError(); // whatever the game left behind
// what the driver thinks the state is after the game drew this eye
{
PFN_GetIntegerv getInt = (PFN_GetIntegerv)eglGetProcAddress("glGetIntegerv");
PFN_IsEnabled isEnabled = (PFN_IsEnabled)eglGetProcAddress("glIsEnabled");
if (getInt != NULL && isEnabled != NULL)
{
GLint drawFb = -1, readFb = -1, viewport[4] = {0}, scissor[4] = {0};
getInt(0x8CA6 /*GL_DRAW_FRAMEBUFFER_BINDING*/, &drawFb);
getInt(0x8CAA /*GL_READ_FRAMEBUFFER_BINDING*/, &readFb);
getInt(GL_VIEWPORT, viewport);
getInt(GL_SCISSOR_BOX, scissor);
ALOGI("[vr-dump] eye %d state: draw fb %d, read fb %d, eye fb %u, viewport %d %d %d %d, scissor %s %d %d %d %d, gl error 0x%x",
eye, drawFb, readFb, frameBuffer->FrameBuffers[frameBuffer->TextureSwapChainIndex],
viewport[0], viewport[1], viewport[2], viewport[3], isEnabled(GL_SCISSOR_TEST) ? "on" : "off",
scissor[0], scissor[1], scissor[2], scissor[3], (unsigned int)drawError);
}
}
if (config.probe)
{
// not the game's picture: a flat colour, to see if the readback works at all
PFN_ClearColor driverClearColor = (PFN_ClearColor)eglGetProcAddress("glClearColor");
PFN_Clear driverClear = (PFN_Clear)eglGetProcAddress("glClear");
PFN_Disable driverDisable = (PFN_Disable)eglGetProcAddress("glDisable");
if (driverClearColor != NULL && driverClear != NULL && driverDisable != NULL)
{
driverBind(GL_FRAMEBUFFER, frameBuffer->FrameBuffers[frameBuffer->TextureSwapChainIndex]);
driverDisable(GL_SCISSOR_TEST);
driverClearColor(eye == 0 ? 1.0f : 0.0f, 0.0f, eye == 0 ? 0.0f : 1.0f, 1.0f);
driverClear(GL_COLOR_BUFFER_BIT);
}
}
driverError(); // clear
driverBind(GL_READ_FRAMEBUFFER, frameBuffer->FrameBuffers[frameBuffer->TextureSwapChainIndex]);
driverRead(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
const GLenum error = driverError();
driverBind(GL_READ_FRAMEBUFFER, 0);
driverFinish();
// how much of the picture isn't black, for the log
long lit = 0;
for (long px = 0; px < (long)width * height; px++)
{
if (pixels[px * 4] | pixels[px * 4 + 1] | pixels[px * 4 + 2])
{
lit++;
}
}
char path[256];
snprintf(path, sizeof(path), DUMP_DIR "/vr-eye%d.bmp", eye);
const bool ok = VrDiagCore_WriteBmp(path, pixels, width, height);
free(pixels);
ALOGI("[vr-dump] eye %d at rendered frame %ld: %dx%d, %s, gl error 0x%x, %.1f%% not black, game %s", eye, renderedFrames,
width, height, ok ? path : "write failed", (unsigned int)error, 100.0 * lit / ((double)width * height), connectionName());
dumpEyeDone[eye] = true;
if (dumpEyeDone[0] && dumpEyeDone[1])
{
dumpState();
dumpDone = true;
ALOGI("[vr-dump] state written to " DUMP_DIR "/vr-state.txt");
}
}
void VrDiag_FrameBegin(void)
{
if (!enabled)
{
return;
}
const double now = TBXR_GetTimeInMilliSeconds();
if (threadStart == 0.0)
threadStart = threadCpuMs();
frames++;
if (TBXR_ShouldRender())
{
renderedFrames++;
}
// perf bookkeeping
if (windowStart == 0.0)
{
windowStart = now;
}
else if (lastBegin != 0.0)
{
const double period = gAppState.FrameState.predictedDisplayPeriod / 1e6; // ns to ms
if (period > 0.0 && (now - lastBegin) > period * 1.5)
{
windowMissed++;
}
}
lastBegin = now;
windowFrames++;
if (config.perf && (now - windowStart) >= 10000.0)
{
const double seconds = (now - windowStart) / 1000.0;
// the thread's own CPU time over the window, against the wall time: what's left is waiting
const double threadMs = threadCpuMs();
const double cpuPerFrame = windowFrames ? (threadMs - threadStart) / windowFrames : 0.0;
ALOGI("[vr-perf] fps=%.1f missed=%ld cpu_ms=%.2f thread_cpu_ms=%.2f wait_ms=%.2f acquire_ms=%.2f period_ms=%.2f frames=%ld rendered=%ld size=%d game=%s",
windowFrames / seconds, windowMissed, windowFrames ? cpuSum / windowFrames : 0.0, cpuPerFrame,
windowFrames ? waitSum / windowFrames : 0.0, windowFrames ? acquireSum / windowFrames : 0.0,
gAppState.FrameState.predictedDisplayPeriod / 1e6, frames, renderedFrames,
gAppState.Renderer.FrameBuffer[0].Width, connectionName());
threadStart = threadMs;
waitSum = acquireSum = 0.0;
windowStart = now;
windowFrames = 0;
windowMissed = 0;
cpuSum = 0.0;
}
// load a map without the menus, once the engine has been running a moment
if (config.autostart && !mapStarted && renderedFrames >= 150)
{
char command[96];
if (config.load[0])
snprintf(command, sizeof(command), "load %s\n", config.load);
else
snprintf(command, sizeof(command), "map %s\n", config.map[0] ? config.map : "c1a0");
Cbuf_AddText(command);
mapStarted = true;
mapStartFrame = renderedFrames;
ALOGI("[vr-diag] autostart: %s", command);
}
// console commands once the map is up (cheats, a cvar, a give)
if (config.cmd[0] && !cmdDone && mapStarted && renderedFrames >= mapStartFrame + 300)
{
char command[sizeof(config.cmd) + 2];
snprintf(command, sizeof(command), "%s\n", config.cmd);
Cbuf_AddText(command);
cmdDone = true;
ALOGI("[vr-diag] cmd: %s", config.cmd);
}
static double firstFrameMs = 0.0;
if (firstFrameMs == 0.0)
firstFrameMs = now;
if (config.exitSeconds > 0 && (now - firstFrameMs) >= config.exitSeconds * 1000.0)
{
ALOGI("[vr-diag] exit_seconds %d reached, shutting down", config.exitSeconds);
config.exitSeconds = 0;
TBXR_exit(0);
}
if (config.exitAfter > 0 && frames >= config.exitAfter)
{
ALOGI("[vr-diag] exit_after %d reached, shutting down", config.exitAfter);
config.exitAfter = 0;
TBXR_exit(0);
}
}
void VrDiag_FrameEnd(void)
{
if (enabled && lastBegin != 0.0)
{
cpuSum += TBXR_GetTimeInMilliSeconds() - lastBegin;
}
}
@@ -0,0 +1,31 @@
// Unattended test switches for the Steam Frame build: the headset on a desk, no
// one wearing it. They come from vr-diag.ini in the data folder and do nothing
// when that file isn't there. See docs/VR_ARCHITECTURE.md.
#ifndef VRDIAG_H
#define VRDIAG_H
#include "TBXR_Common.h"
#include "VrInputFrame.h"
void VrDiag_Init(void); // once, reads vr-diag.ini
bool VrDiag_ForceRender(void); // draw and run while shouldRender is false
void VrDiag_FrameBegin(void); // each XR frame, after xrWaitFrame and the input
void VrDiag_FrameEnd(void); // each XR frame, after xrEndFrame
void VrDiag_AfterEye(int eye); // the eye is drawn, still bound, before it's released
// Stand-ins for what the runtime can't give an unworn headset. They return false
// (and touch nothing) unless force_render is on.
bool VrDiag_SyntheticHead(XrPosef* pose);
bool VrDiag_SyntheticViews(XrView* views);
bool VrDiag_SyntheticHand(int hand, ovrTrackedController* controller);
// diag_press: holds buttons down for a while, so the controller map can run with nobody holding
// controllers. Returns true when the buttons were changed; Injecting says there are some to come.
bool VrDiag_InjectInputs(frameRaw_t* raw);
bool VrDiag_Injecting(void);
// time spent in the blocking XR calls, for the [vr-perf] split between work and waiting
enum { VRDIAG_TIME_WAIT_FRAME, VRDIAG_TIME_ACQUIRE };
void VrDiag_NoteTiming(int what, double ms);
#endif
@@ -0,0 +1,267 @@
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "VrDiagCore.h"
#include "VrFrameMap.h"
void VrDiagCore_Defaults(vrDiagConfig_t* config)
{
memset(config, 0, sizeof(*config));
config->perf = 1;
config->gap = 0.5f;
}
static const struct { const char* name; int input; } pressNames[] = {
{ "LT", FI_L_TRIGGER }, { "RT", FI_R_TRIGGER }, { "LG", FI_L_GRIP }, { "RG", FI_R_GRIP },
{ "LB", FI_L_BUMPER }, { "RB", FI_R_BUMPER }, { "LC", FI_L_STICK_CLICK }, { "RC", FI_R_STICK_CLICK },
{ "A", FI_A }, { "B", FI_B }, { "X", FI_X }, { "Y", FI_Y },
{ "MENU", FI_MENU_SHORT }, { "MENUHOLD", FI_MENU_HOLD }, { "VIEW", FI_VIEW_SHORT },
{ "UP", FI_DPAD_UP }, { "DOWN", FI_DPAD_DOWN }, { "LEFT", FI_DPAD_LEFT }, { "RIGHT", FI_DPAD_RIGHT },
{ "LSUP", FI_L_STICK_UP }, { "LSDOWN", FI_L_STICK_DOWN }, { "LSLEFT", FI_L_STICK_LEFT }, { "LSRIGHT", FI_L_STICK_RIGHT },
{ "RSUP", FI_R_STICK_UP }, { "RSDOWN", FI_R_STICK_DOWN }, { "RSLEFT", FI_R_STICK_LEFT }, { "RSRIGHT", FI_R_STICK_RIGHT },
};
// "A@300+30, LG@400+20": a button, the frame it goes down at (after the map has started) and for how many
static void parsePresses(const char* value, vrDiagConfig_t* config)
{
char copy[256];
snprintf(copy, sizeof(copy), "%s", value);
config->pressCount = 0;
char* save = NULL;
for (char* item = strtok_r(copy, ", ", &save); item != NULL && config->pressCount < VRDIAG_MAX_PRESSES; item = strtok_r(NULL, ", ", &save))
{
char* at = strchr(item, '@');
char* plus = at ? strchr(at, '+') : NULL;
if (at == NULL || plus == NULL)
continue;
*at = '\0';
*plus = '\0';
int input = -1;
for (size_t i = 0; i < sizeof(pressNames) / sizeof(pressNames[0]); i++)
if (strcasecmp(item, pressNames[i].name) == 0)
input = pressNames[i].input;
const int start = atoi(at + 1);
const int length = atoi(plus + 1);
if (input < 0 || start < 0 || length <= 0)
continue;
vrDiagPress_t* press = &config->presses[config->pressCount++];
press->input = input;
press->start = start;
press->length = length;
}
}
static char* trim(char* text)
{
while (isspace((unsigned char)*text))
{
text++;
}
char* end = text + strlen(text);
while (end > text && isspace((unsigned char)end[-1]))
{
*--end = '\0';
}
return text;
}
static bool parseInt(const char* value, int* out)
{
if (strcasecmp(value, "true") == 0 || strcasecmp(value, "on") == 0 || strcasecmp(value, "yes") == 0)
{
*out = 1;
return true;
}
if (strcasecmp(value, "false") == 0 || strcasecmp(value, "off") == 0 || strcasecmp(value, "no") == 0)
{
*out = 0;
return true;
}
char* end = NULL;
long number = strtol(value, &end, 10);
if (end == value || *end != '\0')
{
return false;
}
*out = (int)number;
return true;
}
static bool parseFloat(const char* value, float* out)
{
char* end = NULL;
float number = strtof(value, &end);
if (end == value || *end != '\0')
{
return false;
}
*out = number;
return true;
}
int VrDiagCore_Parse(const char* text, vrDiagConfig_t* config)
{
int found = 0;
char* copy = strdup(text);
if (copy == NULL)
{
return 0;
}
for (char* line = strtok(copy, "\r\n"); line != NULL; line = strtok(NULL, "\r\n"))
{
line = trim(line);
if (*line == '\0' || *line == '#' || *line == ';')
{
continue;
}
char* equals = strchr(line, '=');
if (equals == NULL)
{
continue;
}
*equals = '\0';
const char* key = trim(line);
const char* value = trim(equals + 1);
int number = 0;
float real = 0.0f;
if (strcasecmp(key, "force_render") == 0 && parseInt(value, &number))
{
config->forceRender = number != 0;
}
else if (strcasecmp(key, "diag_yaw") == 0 && parseFloat(value, &real))
{
config->yaw = real;
}
else if (strcasecmp(key, "diag_roll") == 0 && parseFloat(value, &real))
{
config->roll = real;
}
else if (strcasecmp(key, "diag_hand_yaw") == 0 && parseFloat(value, &real))
{
config->handYaw = real;
}
else if (strcasecmp(key, "diag_hand_pitch") == 0 && parseFloat(value, &real))
{
config->handPitch = real;
}
else if (strcasecmp(key, "diag_gap") == 0 && parseFloat(value, &real))
{
config->gap = real > 0.0f ? real : 0.5f;
}
else if (strcasecmp(key, "diag_press") == 0 && *value != '\0')
{
parsePresses(value, config);
}
else if (strcasecmp(key, "autostart") == 0 && parseInt(value, &number))
{
config->autostart = number != 0;
}
else if (strcasecmp(key, "load") == 0 && *value != '\0')
{
snprintf(config->load, sizeof(config->load), "%s", value);
config->autostart = 1;
}
else if (strcasecmp(key, "exit_seconds") == 0 && parseInt(value, &number))
{
config->exitSeconds = number > 0 ? number : 0;
}
else if (strcasecmp(key, "map") == 0 && *value != '\0')
{
snprintf(config->map, sizeof(config->map), "%s", value);
config->autostart = 1;
}
else if (strcasecmp(key, "cmd") == 0 && *value != '\0')
{
snprintf(config->cmd, sizeof(config->cmd), "%s", value);
}
else if (strcasecmp(key, "dump_frame") == 0 && parseInt(value, &number))
{
config->dumpFrame = number > 0 ? number : 0;
}
else if (strcasecmp(key, "exit_after") == 0 && parseInt(value, &number))
{
config->exitAfter = number > 0 ? number : 0;
}
else if (strcasecmp(key, "probe") == 0 && parseInt(value, &number))
{
config->probe = number != 0;
}
else if (strcasecmp(key, "perf") == 0 && parseInt(value, &number))
{
config->perf = number != 0;
}
else if (strcasecmp(key, "oneshot") == 0 && parseInt(value, &number))
{
config->oneshot = number != 0;
}
else
{
continue;
}
found++;
}
free(copy);
config->keysSeen = found;
return found;
}
static void put16(unsigned char* p, unsigned int v) { p[0] = v & 255; p[1] = (v >> 8) & 255; }
static void put32(unsigned char* p, unsigned int v) { put16(p, v & 0xffff); put16(p + 2, v >> 16); }
bool VrDiagCore_WriteBmp(const char* path, const unsigned char* rgba, int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
const int stride = (width * 3 + 3) & ~3;
const unsigned int imageSize = (unsigned int)stride * (unsigned int)height;
unsigned char header[54];
memset(header, 0, sizeof(header));
header[0] = 'B';
header[1] = 'M';
put32(header + 2, 54 + imageSize);
put32(header + 10, 54);
put32(header + 14, 40);
put32(header + 18, (unsigned int)width);
put32(header + 22, (unsigned int)height); // positive: rows are bottom to top, as given
put16(header + 26, 1);
put16(header + 28, 24);
put32(header + 34, imageSize);
FILE* file = fopen(path, "wb");
if (file == NULL)
{
return false;
}
bool ok = fwrite(header, 1, sizeof(header), file) == sizeof(header);
unsigned char* row = (unsigned char*)calloc(1, (size_t)stride);
if (row == NULL)
{
fclose(file);
return false;
}
for (int y = 0; y < height && ok; y++)
{
const unsigned char* src = rgba + (size_t)y * (size_t)width * 4;
for (int x = 0; x < width; x++)
{
row[x * 3 + 0] = src[x * 4 + 2];
row[x * 3 + 1] = src[x * 4 + 1];
row[x * 3 + 2] = src[x * 4 + 0];
}
ok = fwrite(row, 1, (size_t)stride, file) == (size_t)stride;
}
free(row);
return fclose(file) == 0 && ok;
}
@@ -0,0 +1,46 @@
// Unattended test switches for the Steam Frame build, the parts that need no
// OpenXR or GL so they can be tested on the host (tools/steam_frame/tests).
#ifndef VRDIAGCORE_H
#define VRDIAGCORE_H
#include <stdbool.h>
#define VRDIAG_MAX_PRESSES 24
typedef struct {
int input; // FI_* of VrFrameMap.h
int start; // frames after the map has started
int length; // frames held
} vrDiagPress_t;
typedef struct {
int forceRender; // draw and run the game while the runtime says shouldRender=false
float yaw; // synthetic head yaw in degrees (turning left is positive)
float handYaw, handPitch; // the synthetic hands' aim, turned from the head's (degrees)
float gap; // metres between the synthetic hands (they're parked either side of the chest)
vrDiagPress_t presses[VRDIAG_MAX_PRESSES]; // buttons to hold down for a while
int pressCount;
float roll; // synthetic head roll in degrees (tilting the head to the left is positive)
int autostart; // load a map without touching the menus
char load[64]; // a save to load instead of starting a map (a load key implies autostart)
int exitSeconds; // leave after this many seconds of rendering, 0 = off
char map[64]; // which one (a map set here starts it as well)
char cmd[128]; // console commands (; between them) to run once, about 4 s after the map starts
int dumpFrame; // dump both eyes on this rendered frame, 0 = off
int exitAfter; // leave through the normal shutdown after this many frames, 0 = off
int perf; // the [vr-perf] line every 10 s
int probe; // clear each eye to a colour (red, blue) right before the dump, to test the readback
int oneshot; // rename the file once it has been read
int keysSeen; // how many known keys were in the file
} vrDiagConfig_t;
void VrDiagCore_Defaults(vrDiagConfig_t* config);
// "key = value" lines, # or ; for comments, keys in any case. Unknown keys and
// values that don't parse are ignored. Returns how many known keys it found.
int VrDiagCore_Parse(const char* text, vrDiagConfig_t* config);
// A 24-bit BMP from RGBA rows as glReadPixels gives them (bottom row first).
bool VrDiagCore_WriteBmp(const char* path, const unsigned char* rgba, int width, int height);
#endif
@@ -0,0 +1,101 @@
#include <math.h>
#include "VrEyeMath.h"
void VrEye_ToEngineOffset(const float position[3], float worldScale, float out[3])
{
out[0] = -position[2] * worldScale; // forward
out[1] = -position[0] * worldScale; // left
out[2] = position[1] * worldScale; // up
}
bool VrEye_CantAxisAngle(const float quat[4], float axis[3], float* degrees)
{
float x = quat[0], y = quat[1], z = quat[2], w = quat[3];
const float length = sqrtf(x * x + y * y + z * z + w * w);
if (length < 1e-6f)
{
return false;
}
x /= length; y /= length; z /= length; w /= length;
// the same rotation by the shorter way round
if (w < 0.0f)
{
x = -x; y = -y; z = -z; w = -w;
}
const float s = sqrtf(x * x + y * y + z * z);
const float angle = 2.0f * atan2f(s, w);
if (angle < 1e-4f || s < 1e-6f)
{
return false;
}
// the axis changes basis like a vector does (the axes differ by a proper rotation)
axis[0] = -z / s;
axis[1] = -x / s;
axis[2] = y / s;
*degrees = angle * (180.0f / (float)M_PI);
return true;
}
float VrEye_AxisPixel(float angleLeft, float angleRight, int widthPx)
{
const float tanL = tanf(angleLeft);
const float tanR = tanf(angleRight);
if (tanR - tanL <= 0.0f)
{
return widthPx * 0.5f;
}
return -tanL / (tanR - tanL) * (float)widthPx;
}
float VrEye_FocalPixels(float angleLeft, float angleRight, int widthPx)
{
const float span = tanf(angleRight) - tanf(angleLeft);
return span > 0.0f ? (float)widthPx / span : 0.0f;
}
float VrEye_HudShiftPixels(float eyeX, float angleLeft, float angleRight, int widthPx, float distanceM)
{
if (distanceM <= 0.0f)
{
return 0.0f;
}
// a point straight ahead of the head lies at -eyeX from this eye's own axis
return -eyeX / distanceM * VrEye_FocalPixels(angleLeft, angleRight, widthPx);
}
float VrEye_ProjectX(float eyeX, float pointX, float pointZ, float angleLeft, float angleRight, int widthPx)
{
const float tanL = tanf(angleLeft);
const float tanR = tanf(angleRight);
const float slope = (pointX - eyeX) / -pointZ;
return (slope - tanL) / (tanR - tanL) * (float)widthPx;
}
void VrEye_ComposePose(const float a[7], const float b[7], float out[7])
{
const float ax = a[3], ay = a[4], az = a[5], aw = a[6];
const float bx = b[3], by = b[4], bz = b[5], bw = b[6];
// position: a's position plus b's rotated by a's orientation
// v' = v + 2w(q x v) + 2(q x (q x v))
const float vx = b[0], vy = b[1], vz = b[2];
const float cx = ay * vz - az * vy;
const float cy = az * vx - ax * vz;
const float cz = ax * vy - ay * vx;
const float dx = ay * cz - az * cy;
const float dy = az * cx - ax * cz;
const float dz = ax * cy - ay * cx;
out[0] = a[0] + vx + 2.0f * (aw * cx + dx);
out[1] = a[1] + vy + 2.0f * (aw * cy + dy);
out[2] = a[2] + vz + 2.0f * (aw * cz + dz);
// orientation: a * b
out[3] = aw * bx + ax * bw + ay * bz - az * by;
out[4] = aw * by - ax * bz + ay * bw + az * bx;
out[5] = aw * bz + ax * by - ay * bx + az * bw;
out[6] = aw * bw - ax * bx - ay * by - az * bz;
}
@@ -0,0 +1,36 @@
// Steam Frame: where each eye is, from what the runtime says, and the 2D shift that puts
// the HUD at a distance. Plain C with no OpenXR or engine types, so the host tests can use it.
// OpenXR axes: x right, y up, -z forward. Engine (Quake) axes: x forward, y left, z up.
#ifndef VREYEMATH_H
#define VREYEMATH_H
#include <stdbool.h>
// An eye's position in head space (OpenXR axes, metres) as an offset in the engine's
// head-aligned axes, scaled to world units.
void VrEye_ToEngineOffset(const float position[3], float worldScale, float out[3]);
// The eye's rotation relative to the head (OpenXR quaternion x y z w) as an axis in the
// engine's axes and an angle in degrees. Returns false when there's no rotation to speak of.
bool VrEye_CantAxisAngle(const float quat[4], float axis[3], float* degrees);
// Pixels from the left of an eye image to where straight ahead lands, for an asymmetric
// frustum (angles in radians as OpenXR gives them: angleLeft is negative).
float VrEye_AxisPixel(float angleLeft, float angleRight, int widthPx);
// Focal length in pixels along x.
float VrEye_FocalPixels(float angleLeft, float angleRight, int widthPx);
// How far to shift 2D content in an eye's image (positive: right) so that it sits at
// distanceM straight ahead of the head, given that eye's x position in head space.
float VrEye_HudShiftPixels(float eyeX, float angleLeft, float angleRight, int widthPx, float distanceM);
// Pixels from the left of an eye image where a point at (x, z) in head space lands
// (z negative is ahead), the eye being at eyeX. For tests and measuring.
float VrEye_ProjectX(float eyeX, float pointX, float pointZ, float angleLeft, float angleRight, int widthPx);
// pose = a pose b: b (an eye in head space) placed by a (the head in the stage). A pose is
// position x y z then quaternion x y z w.
void VrEye_ComposePose(const float a[7], const float b[7], float out[7]);
#endif
@@ -0,0 +1,127 @@
#include "VrFrameMap.h"
// The map. One physical input does one thing in a context: the rows of an input must not overlap
// (the host test checks that, and that every action can be reached). The same action on two
// inputs is an alias.
static const frameMapRow_t rows[] = {
// ---- playing
{ FI_R_TRIGGER, ROLE_TRIGGER_WEAPON, 0, CTX_MENU, ACT_FIRE, "weapon hand trigger" },
{ FI_L_TRIGGER, ROLE_TRIGGER_OTHER, 0, CTX_MENU, ACT_ALTFIRE, "other hand trigger" },
{ FI_A, 0, 0, CTX_MENU, ACT_JUMP, "" },
{ FI_B, 0, 0, CTX_MENU, ACT_CROUCH_HOLD, "" },
{ FI_X, 0, 0, CTX_MENU, ACT_USE, "" },
{ FI_Y, 0, 0, CTX_MENU, ACT_RELOAD, "" },
{ FI_L_STICK_CLICK, 0, 0, CTX_MENU, ACT_CROUCH_TOGGLE, "" },
{ FI_R_STICK_CLICK, 0, CTX_OFF_ZONE, CTX_MENU, ACT_QUICKSAVE, "off hand behind the head" },
{ FI_R_STICK_CLICK, 0, 0, CTX_MENU | CTX_OFF_ZONE, ACT_FLASHLIGHT, "" },
{ FI_VIEW_SHORT, 0, CTX_OFF_ZONE, CTX_MENU, ACT_QUICKLOAD, "off hand behind the head" },
{ FI_VIEW_SHORT, 0, CTX_MULTI, CTX_MENU | CTX_OFF_ZONE, ACT_SCOREBOARD, "multiplayer" },
{ FI_VIEW_SHORT, 0, 0, CTX_MENU | CTX_OFF_ZONE | CTX_MULTI, ACT_FLASHLIGHT, "" },
{ FI_L_STICK_UP, 0, 0, CTX_MENU, ACT_MOVE, "move" },
{ FI_L_STICK_DOWN, 0, 0, CTX_MENU, ACT_MOVE, "move" },
{ FI_L_STICK_LEFT, 0, 0, CTX_MENU, ACT_MOVE, "move" },
{ FI_L_STICK_RIGHT, 0, 0, CTX_MENU, ACT_MOVE, "move" },
{ FI_R_STICK_LEFT, 0, 0, CTX_MENU, ACT_TURN, "snap or smooth turn" },
{ FI_R_STICK_RIGHT, 0, 0, CTX_MENU, ACT_TURN, "snap or smooth turn" },
// (right stick up and down: nothing in the game, they scroll in a menu)
{ FI_L_BUMPER, 0, 0, CTX_MENU, ACT_PREV_WEAPON, "" },
{ FI_R_BUMPER, 0, 0, CTX_MENU, ACT_NEXT_WEAPON, "" },
{ FI_DPAD_LEFT, 0, 0, CTX_MENU, ACT_PREV_WEAPON, "alias of the left bumper" },
{ FI_DPAD_RIGHT, 0, 0, CTX_MENU, ACT_NEXT_WEAPON, "alias of the right bumper" },
{ FI_DPAD_UP, 0, 0, CTX_MENU, ACT_LAST_WEAPON, "" },
{ FI_DPAD_DOWN, 0, 0, CTX_MENU, ACT_LASER, "laser sight, steadies a scope" },
{ FI_R_GRIP, ROLE_GRIP_WEAPON, CTX_DOM_ZONE, CTX_MENU, ACT_BACKPACK, "the crowbar, from behind the head" },
{ FI_R_GRIP, ROLE_GRIP_WEAPON, 0, CTX_MENU | CTX_DOM_ZONE, ACT_USE, "alias of X: grab, push, pull, press" },
{ FI_L_GRIP, ROLE_GRIP_OTHER, 0, CTX_MENU | CTX_OFF_GRIP_AWAY, ACT_STEADY, "two-handed hold, scope: near the weapon" },
{ FI_L_GRIP, ROLE_GRIP_OTHER, CTX_OFF_GRIP_AWAY, CTX_MENU, ACT_USE_OFF, "grab with this hand: well away from the weapon" },
{ FI_MENU_SHORT, 0, 0, CTX_MENU, ACT_PAUSE, "" },
{ FI_MENU_HOLD, 0, 0, CTX_MENU, ACT_QUICKSAVE, "held for half a second" },
{ FI_VIEW_HOLD_1S, 0, 0, 0, ACT_RECENTRE, "" },
{ FI_VIEW_HOLD_3S, 0, 0, 0, ACT_RECENTRE_HEIGHT, "" },
// ---- in a menu (the laser points from the weapon hand)
{ FI_R_TRIGGER, ROLE_TRIGGER_WEAPON, CTX_MENU, 0, ACT_MENU_CLICK, "pointer click" },
{ FI_A, 0, CTX_MENU, 0, ACT_MENU_CONFIRM, "" },
{ FI_B, 0, CTX_MENU, 0, ACT_MENU_BACK, "" },
{ FI_MENU_SHORT, 0, CTX_MENU, 0, ACT_MENU_BACK, "alias of B" },
{ FI_VIEW_SHORT, 0, CTX_MENU, 0, ACT_MENU_BACK, "alias of B" },
{ FI_DPAD_UP, 0, CTX_MENU, 0, ACT_MENU_UP, "" },
{ FI_DPAD_DOWN, 0, CTX_MENU, 0, ACT_MENU_DOWN, "" },
{ FI_DPAD_LEFT, 0, CTX_MENU, 0, ACT_MENU_LEFT, "" },
{ FI_DPAD_RIGHT, 0, CTX_MENU, 0, ACT_MENU_RIGHT, "" },
{ FI_L_STICK_UP, 0, CTX_MENU, 0, ACT_MENU_UP, "alias of the D-pad" },
{ FI_L_STICK_DOWN, 0, CTX_MENU, 0, ACT_MENU_DOWN, "alias of the D-pad" },
{ FI_L_STICK_LEFT, 0, CTX_MENU, 0, ACT_MENU_LEFT, "alias of the D-pad" },
{ FI_L_STICK_RIGHT, 0, CTX_MENU, 0, ACT_MENU_RIGHT, "alias of the D-pad" },
{ FI_L_BUMPER, 0, CTX_MENU, 0, ACT_MENU_PAGE_PREV, "previous tab or page" },
{ FI_R_BUMPER, 0, CTX_MENU, 0, ACT_MENU_PAGE_NEXT, "next tab or page" },
{ FI_R_STICK_UP, 0, CTX_MENU, 0, ACT_MENU_SCROLL_UP, "scroll" },
{ FI_R_STICK_DOWN, 0, CTX_MENU, 0, ACT_MENU_SCROLL_DOWN, "scroll" },
};
const frameMapRow_t* VrFrameMap_Rows(int* count)
{
*count = (int)(sizeof(rows) / sizeof(rows[0]));
return rows;
}
int VrFrameMap_RoleInput(int role, bool leftHanded)
{
switch (role)
{
case ROLE_TRIGGER_WEAPON: return leftHanded ? FI_L_TRIGGER : FI_R_TRIGGER;
case ROLE_TRIGGER_OTHER: return leftHanded ? FI_R_TRIGGER : FI_L_TRIGGER;
case ROLE_GRIP_WEAPON: return leftHanded ? FI_L_GRIP : FI_R_GRIP;
case ROLE_GRIP_OTHER: return leftHanded ? FI_R_GRIP : FI_L_GRIP;
default: return -1;
}
}
int VrFrameMap_Action(int input, int context, bool leftHanded)
{
const int count = (int)(sizeof(rows) / sizeof(rows[0]));
for (int i = 0; i < count; i++)
{
const frameMapRow_t* row = &rows[i];
const int rowInput = row->role ? VrFrameMap_RoleInput(row->role, leftHanded) : row->input;
if (rowInput != input)
{
continue;
}
if ((context & row->require) != row->require || (context & row->forbid) != 0)
{
continue;
}
return row->action;
}
return ACT_NONE;
}
const char* VrFrameMap_InputName(int input)
{
static const char* const names[FI_COUNT] = {
"left trigger", "right trigger", "left grip", "right grip", "left bumper", "right bumper",
"left stick click", "right stick click", "A", "B", "X", "Y",
"Menu (short)", "Menu (hold 0.5 s)", "View (short)", "View (hold 1 s)", "View (hold 3 s)",
"D-pad up", "D-pad down", "D-pad left", "D-pad right",
"left stick up", "left stick down", "left stick left", "left stick right",
"right stick up", "right stick down", "right stick left", "right stick right"};
return (input >= 0 && input < FI_COUNT) ? names[input] : "?";
}
const char* VrFrameMap_ActionName(int action)
{
static const char* const names[ACT_COUNT] = {
"none",
"fire", "alt fire", "use", "use (other hand)", "reload", "jump", "crouch (hold)", "crouch (toggle)",
"flashlight", "scoreboard", "laser sight / scope steady",
"move", "turn",
"previous weapon", "next weapon", "last weapon",
"backpack weapon", "hold the gun steady",
"pause", "quick save", "quick load",
"recentre", "recentre and set height",
"menu click", "menu confirm", "menu back",
"menu up", "menu down", "menu left", "menu right",
"menu previous tab", "menu next tab", "menu scroll up", "menu scroll down"};
return (action >= 0 && action < ACT_COUNT) ? names[action] : "?";
}
@@ -0,0 +1,81 @@
// Steam Frame: the controller map as one table, so it is easy to change and can be checked on
// the host. It follows Half-Life's own Steam Input gamepad layout (the 25th anniversary update's
// xbox_controller_config_standard.vdf: sticks move and turn, RT fires, LT alt fires, A jump,
// B crouch, X use, Y reload, bumpers and D-pad pick weapons, Start pauses and, held, quick saves,
// Select is the flashlight) with the VR parts added: the grips, and the head for looking. The right
// stick's up and down do nothing in the game (it turns left and right), and the flat screen view is a
// setting (vr_flat_screen), not a button.
//
// The Frame's controllers are a split gamepad: the left one has the D-pad, View, a bumper, trigger,
// grip and stick, the right one has A B X Y, Menu, a bumper, trigger, grip and stick. Handedness
// decides which hand is the weapon hand (so which trigger fires and which grip grabs, and where the
// laser comes from); the buttons stay where they physically are.
#ifndef VRFRAMEMAP_H
#define VRFRAMEMAP_H
#include <stdbool.h>
#include <stdint.h>
// Physical inputs. The stick directions are the stick pushed over a threshold, View and Menu
// come as a short press (on release), and a hold that fires once while it's held.
enum {
FI_L_TRIGGER, FI_R_TRIGGER, FI_L_GRIP, FI_R_GRIP, FI_L_BUMPER, FI_R_BUMPER,
FI_L_STICK_CLICK, FI_R_STICK_CLICK,
FI_A, FI_B, FI_X, FI_Y,
FI_MENU_SHORT, FI_MENU_HOLD, FI_VIEW_SHORT, FI_VIEW_HOLD_1S, FI_VIEW_HOLD_3S,
FI_DPAD_UP, FI_DPAD_DOWN, FI_DPAD_LEFT, FI_DPAD_RIGHT,
FI_L_STICK_UP, FI_L_STICK_DOWN, FI_L_STICK_LEFT, FI_L_STICK_RIGHT,
FI_R_STICK_UP, FI_R_STICK_DOWN, FI_R_STICK_LEFT, FI_R_STICK_RIGHT,
FI_COUNT
};
// What can happen
enum {
ACT_NONE,
ACT_FIRE, ACT_ALTFIRE, ACT_USE, ACT_USE_OFF, ACT_RELOAD, ACT_JUMP, ACT_CROUCH_HOLD, ACT_CROUCH_TOGGLE,
ACT_FLASHLIGHT, ACT_SCOREBOARD, ACT_LASER,
ACT_MOVE, ACT_TURN,
ACT_PREV_WEAPON, ACT_NEXT_WEAPON, ACT_LAST_WEAPON,
ACT_BACKPACK, ACT_STEADY,
ACT_PAUSE, ACT_QUICKSAVE, ACT_QUICKLOAD,
ACT_RECENTRE, ACT_RECENTRE_HEIGHT,
ACT_MENU_CLICK, ACT_MENU_CONFIRM, ACT_MENU_BACK,
ACT_MENU_UP, ACT_MENU_DOWN, ACT_MENU_LEFT, ACT_MENU_RIGHT,
ACT_MENU_PAGE_PREV, ACT_MENU_PAGE_NEXT, ACT_MENU_SCROLL_UP, ACT_MENU_SCROLL_DOWN,
ACT_COUNT
};
// What's going on around the button
enum {
CTX_MENU = 1, // a menu or the console has the keys
CTX_DOM_ZONE = 2, // the weapon hand is behind the head (the backpack)
CTX_OFF_ZONE = 4, // the other hand is behind the head (quick save and load, as on the Quest)
CTX_MULTI = 8, // a multiplayer game
CTX_OFF_GRIP_AWAY = 16 // the other hand's grip was pressed with that hand well away from the weapon
};
// What one physical input does in a context, ACT_NONE when nothing
int VrFrameMap_Action(int input, int context, bool leftHanded);
// The map as rows, for the docs and the tests. role is 0 for a plain input; or one of the
// weapon-hand / other-hand roles below, which the hand setting turns into a physical input.
enum { ROLE_NONE, ROLE_TRIGGER_WEAPON, ROLE_TRIGGER_OTHER, ROLE_GRIP_WEAPON, ROLE_GRIP_OTHER };
typedef struct {
int input; // FI_* (for a role row: one of the right-handed ones)
int role;
int require; // context bits that must be set
int forbid; // context bits that must not be
int action;
const char* note;
} frameMapRow_t;
const frameMapRow_t* VrFrameMap_Rows(int* count);
const char* VrFrameMap_InputName(int input);
const char* VrFrameMap_ActionName(int action);
// The physical input behind a role
int VrFrameMap_RoleInput(int role, bool leftHanded);
#endif
@@ -52,6 +52,11 @@ void updateScopeAngles(float forwardYaw);
bool isBackpack(ovrTrackedController* pTracking);
#ifdef L1VR_STEAM_FRAME
// true while the Steam Frame's controller map is in charge: use comes from the grips and X, not from a gesture
bool VR_FrameHandUse();
#endif
extern char * g_pszBackpackWeapon;
#endif //VRINPUT_H
@@ -144,6 +144,10 @@ void interactWithTouchScreen(ovrTrackedController *tracking, ovrInputStateTracke
float cursorX = -sinf(DEG2RAD(remoteAngles[YAW] - playerYaw)) + 0.5f;
float cursorY = (float)(remoteAngles[PITCH] / 90.0) + 0.5f;
#ifdef L1VR_STEAM_FRAME
// the cursor is where the hand's ray meets the screen, the same place the laser ends
TBXR_ScreenPointer(&cursorX, &cursorY);
#endif
touchEventType t = event_motion;
if (newRemoteTrigState != prevRemoteTrigState)
{
@@ -208,7 +208,12 @@ void HandleInput_Default( ovrInputStateTrackedRemote *pDominantTrackedRemoteNew,
weaponangles[MELEE][PITCH] *= -1.0f;
// Use (Action gesture)
#ifdef L1VR_STEAM_FRAME
// On the Frame the grips do this: the cvar only sets how close the hand has to be
if (vr_gesture_triggered_use->integer && !VR_FrameHandUse()) {
#else
if (vr_gesture_triggered_use->integer) {
#endif
bool gestureUseAllowed = !vr_weapon_stabilised->value;
// Off-hand gesture
float xOffset = hmdPosition[0] - pOffTracking->Pose.position.x;
@@ -0,0 +1,314 @@
/************************************************************************************
Filename : VrInputFrame.c
Content : The Steam Frame controller map (the table in VrFrameMap.c) applied to the game
The scheme code (VrInputDefault.c and the others) reads Quest-shaped controller states: a dominant
and an off hand, and which bits of their buttons mean what. This turns what the Frame's buttons
mean, by the map, into those bits, and into commands for what the scheme code doesn't do.
*************************************************************************************/
#include <common/common.h>
#include <common/library.h>
#include <common/cvardef.h>
#include <common/xash3d_types.h>
#include <engine/keydefs.h>
#include <client/touch.h>
#include <client/client.h>
#include "VrInput.h"
#include "VrCvars.h"
#include "VrFrameMap.h"
#include "VrInputFrame.h"
// every bit of the Quest-shaped state that the map decides
#define MAP_BUTTONS (xrButton_A | xrButton_B | xrButton_X | xrButton_Y | xrButton_Enter | xrButton_Joystick | \
xrButton_LThumb | xrButton_RThumb | xrButton_GripTrigger | xrButton_Trigger | \
xrButton_LShoulder | xrButton_RShoulder)
#define MENU_HOLD_MS 500.0
#define VIEW_HOLD_1S_MS 1000.0
#define VIEW_HOLD_3S_MS 3000.0
typedef struct {
bool wasDown;
double downTime;
int stage;
} holdState_t;
// A button that does one thing when it's tapped (reported once, on release) and another when it's
// held (reported once, when the time is up). Returns the stage reached, 0 for none.
static void holdButton(holdState_t* hold, bool down, double firstMs, double secondMs, bool* tapped, bool* first, bool* second)
{
const double now = TBXR_GetTimeInMilliSeconds();
*tapped = *first = *second = false;
if (down && !hold->wasDown) {
hold->downTime = now;
hold->stage = 0;
}
if (down) {
const double held = now - hold->downTime;
if (hold->stage < 1 && held >= firstMs) {
*first = true;
hold->stage = 1;
}
if (secondMs > 0.0 && hold->stage < 2 && held >= secondMs) {
*second = true;
hold->stage = 2;
}
} else if (hold->wasDown && hold->stage == 0) {
*tapped = true;
}
hold->wasDown = down;
}
static bool frameHandUse = false;
bool VR_FrameHandUse()
{
return frameHandUse;
}
static float handDistance(const ovrTrackedController* a, const ovrTrackedController* b)
{
const float dx = a->Pose.position.x - b->Pose.position.x;
const float dy = a->Pose.position.y - b->Pose.position.y;
const float dz = a->Pose.position.z - b->Pose.position.z;
return sqrtf(dx * dx + dy * dy + dz * dz);
}
// The other hand's grip steadies the gun when that hand is on or near it and grabs things when it is
// well away from it. Decided when the grip goes down, and kept until it comes up. Between the two
// distances the last choice stands, so a normal two-handed hold never turns into a grab.
#define GRIP_STEADY_BELOW_M 0.35f
#define GRIP_GRAB_ABOVE_M 0.55f
static bool flick(float along, float across, float threshold)
{
return along > threshold && along > fabsf(across) * 1.5f;
}
void VrInputFrame_Apply(const frameRaw_t* raw)
{
static uint32_t previousActions[2]; // [0] the bits of actions 0..31, [1] those above
static holdState_t menuHold, viewHold;
static bool gripWasDown = false;
static bool gripZone = false;
static bool crouchToggled = false;
static bool offGripWasDown = false;
static bool offGripAway = false;
static bool scoreboardShown = false;
frameHandUse = true;
const bool leftHanded = (vr_control_scheme != NULL && vr_control_scheme->integer >= 10);
const bool menu = cls.key_dest != key_game;
const bool multiplayer = isMultiplayer();
if (!multiplayer) {
scoreboardShown = false;
}
ovrInputStateTrackedRemote* dom = leftHanded ? &leftTrackedRemoteState_new : &rightTrackedRemoteState_new;
ovrInputStateTrackedRemote* off = leftHanded ? &rightTrackedRemoteState_new : &leftTrackedRemoteState_new;
ovrTrackedController* domTracking = leftHanded ? &leftRemoteTracking_new : &rightRemoteTracking_new;
ovrTrackedController* offTracking = leftHanded ? &rightRemoteTracking_new : &leftRemoteTracking_new;
// the physical inputs that are down
bool menuTap, menuHeld, menuUnused, viewTap, view1s, view3s;
holdButton(&menuHold, raw->menu, MENU_HOLD_MS, 0.0, &menuTap, &menuHeld, &menuUnused);
holdButton(&viewHold, raw->view, VIEW_HOLD_1S_MS, VIEW_HOLD_3S_MS, &viewTap, &view1s, &view3s);
bool down[FI_COUNT] = {0};
down[FI_L_TRIGGER] = raw->triggerLeft > 0.5f;
down[FI_R_TRIGGER] = raw->triggerRight > 0.5f;
down[FI_L_GRIP] = raw->gripLeft > 0.5f;
down[FI_R_GRIP] = raw->gripRight > 0.5f;
down[FI_L_BUMPER] = raw->bumperLeft;
down[FI_R_BUMPER] = raw->bumperRight;
down[FI_L_STICK_CLICK] = raw->stickClickLeft;
down[FI_R_STICK_CLICK] = raw->stickClickRight;
down[FI_A] = raw->a;
down[FI_B] = raw->b;
down[FI_X] = raw->x;
down[FI_Y] = raw->y;
down[FI_MENU_SHORT] = menuTap;
down[FI_MENU_HOLD] = menuHeld;
down[FI_VIEW_SHORT] = viewTap;
down[FI_VIEW_HOLD_1S] = view1s;
down[FI_VIEW_HOLD_3S] = view3s;
down[FI_DPAD_UP] = raw->dpadUp;
down[FI_DPAD_DOWN] = raw->dpadDown;
down[FI_DPAD_LEFT] = raw->dpadLeft;
down[FI_DPAD_RIGHT] = raw->dpadRight;
down[FI_L_STICK_UP] = flick(raw->stickLeftY, raw->stickLeftX, 0.6f);
down[FI_L_STICK_DOWN] = flick(-raw->stickLeftY, raw->stickLeftX, 0.6f);
down[FI_L_STICK_LEFT] = flick(-raw->stickLeftX, raw->stickLeftY, 0.6f);
down[FI_L_STICK_RIGHT] = flick(raw->stickLeftX, raw->stickLeftY, 0.6f);
down[FI_R_STICK_UP] = flick(raw->stickRightY, raw->stickRightX, 0.75f);
down[FI_R_STICK_DOWN] = flick(-raw->stickRightY, raw->stickRightX, 0.75f);
down[FI_R_STICK_LEFT] = flick(-raw->stickRightX, raw->stickRightY, 0.3f);
down[FI_R_STICK_RIGHT] = flick(raw->stickRightX, raw->stickRightY, 0.3f);
// Where the hands are decides some things. The weapon hand's grip is the backpack when it was
// pressed with the hand behind the head, and stays that until it's let go (the crowbar stays out
// while you swing it).
const int weaponGrip = leftHanded ? FI_L_GRIP : FI_R_GRIP;
if (down[weaponGrip] && !gripWasDown) {
gripZone = isBackpack(domTracking);
}
gripWasDown = down[weaponGrip];
const int offGrip = leftHanded ? FI_R_GRIP : FI_L_GRIP;
if (down[offGrip] && !offGripWasDown) {
const float apart = handDistance(domTracking, offTracking);
if (apart < GRIP_STEADY_BELOW_M) {
offGripAway = false;
} else if (apart > GRIP_GRAB_ABOVE_M) {
offGripAway = true;
}
ALOGI("[vr] other hand grip: %s (hands %.2f m apart)", offGripAway ? "interact" : "steady the gun", apart);
}
offGripWasDown = down[offGrip];
int context = 0;
if (offGripAway) context |= CTX_OFF_GRIP_AWAY;
if (menu) context |= CTX_MENU;
if (down[weaponGrip] ? gripZone : isBackpack(domTracking)) context |= CTX_DOM_ZONE;
if (isBackpack(offTracking) && !multiplayer) context |= CTX_OFF_ZONE;
if (multiplayer) context |= CTX_MULTI;
// say what was pressed and what the map made of it, once a press, for the log (not the stick directions)
static bool wasDown[FI_COUNT];
for (int input = 0; input < FI_COUNT; input++) {
const int mapped = VrFrameMap_Action(input, context, leftHanded);
if (down[input] && !wasDown[input] && mapped != ACT_NONE && mapped != ACT_MOVE && mapped != ACT_TURN) {
ALOGI("[vr] %s -> %s%s%s", VrFrameMap_InputName(input),
VrFrameMap_ActionName(mapped),
menu ? " (menu)" : "", (context & CTX_OFF_ZONE) ? " (hand behind the head)" : "");
}
wasDown[input] = down[input];
}
// what they do: a bit for each action
uint32_t actions[2] = {0, 0};
uint32_t quickSaveFromMenuHold = 0;
for (int input = 0; input < FI_COUNT; input++) {
if (!down[input]) {
continue;
}
const int action = VrFrameMap_Action(input, context, leftHanded);
if (action == ACT_NONE) {
continue;
}
actions[action / 32] |= 1u << (action % 32);
if (input == FI_MENU_HOLD && action == ACT_QUICKSAVE) {
quickSaveFromMenuHold = 1;
}
}
#define HAS(a) ((actions[(a) / 32] & (1u << ((a) % 32))) != 0)
#define PRESSED(a) (HAS(a) && !(previousActions[(a) / 32] & (1u << ((a) % 32))))
#define RELEASED(a) (!HAS(a) && (previousActions[(a) / 32] & (1u << ((a) % 32))))
if (PRESSED(ACT_CROUCH_TOGGLE)) {
crouchToggled = !crouchToggled;
}
// The Quest-shaped states the scheme code reads. The dominant hand: trigger fires (or clicks in a
// menu), grip (only the backpack, from behind the head), stick turns, use on the stick click
// bit, crouch and jump on its two buttons. The off hand: grip steadies the gun, its stick
// moves, its two buttons are the torch and (with the hand behind the head) quick save and
// load, the stick click bit is the laser sight.
const uint32_t domCrouch = leftHanded ? xrButton_X : xrButton_A;
const uint32_t domJump = leftHanded ? xrButton_Y : xrButton_B;
const uint32_t offTorch = leftHanded ? xrButton_A : xrButton_X;
const uint32_t offLoad = leftHanded ? xrButton_B : xrButton_Y;
leftTrackedRemoteState_new.Buttons &= ~MAP_BUTTONS;
rightTrackedRemoteState_new.Buttons &= ~MAP_BUTTONS;
if (HAS(ACT_FIRE) || HAS(ACT_MENU_CLICK)) dom->Buttons |= xrButton_Trigger;
if (HAS(ACT_BACKPACK)) dom->Buttons |= xrButton_GripTrigger;
if (HAS(ACT_STEADY)) off->Buttons |= xrButton_GripTrigger;
if (HAS(ACT_JUMP)) dom->Buttons |= domJump;
if (HAS(ACT_CROUCH_HOLD) || crouchToggled) dom->Buttons |= domCrouch;
if (HAS(ACT_USE)) dom->Buttons |= xrButton_Joystick;
// what the Quest does with its off-hand buttons, with the hand behind the head
if (HAS(ACT_FLASHLIGHT) || (HAS(ACT_QUICKSAVE) && !quickSaveFromMenuHold)) off->Buttons |= offTorch;
if (HAS(ACT_QUICKLOAD)) off->Buttons |= offLoad;
if (HAS(ACT_LASER)) off->Buttons |= xrButton_Joystick;
// one escape for pause and for every way back
if (HAS(ACT_PAUSE) || HAS(ACT_MENU_BACK)) leftTrackedRemoteState_new.Buttons |= xrButton_Enter;
// triggers and sticks: the left stick is always the move stick and the right stick the turn stick
// (the weapon hand only decides the aim, the triggers and the grips), and there's no weapon
// select on the turn stick, the bumpers and the D-pad do that.
dom->IndexTrigger = leftHanded ? raw->triggerLeft : raw->triggerRight;
off->IndexTrigger = 0.0f;
dom->GripTrigger = HAS(ACT_BACKPACK) ? 1.0f : 0.0f;
off->GripTrigger = HAS(ACT_STEADY) ? 1.0f : 0.0f;
dom->Joystick.x = raw->stickRightX;
dom->Joystick.y = 0.0f;
off->Joystick.x = raw->stickLeftX;
off->Joystick.y = raw->stickLeftY;
// commands the scheme code doesn't have
if (PRESSED(ACT_RECENTRE)) {
TBXR_RecenterToHead(false);
TBXR_Vibrate(80, 0, 0.6f);
TBXR_Vibrate(80, 1, 0.6f);
}
if (PRESSED(ACT_RECENTRE_HEIGHT)) {
TBXR_RecenterToHead(true);
TBXR_Vibrate(80, 0, 0.6f);
TBXR_Vibrate(80, 1, 0.6f);
}
// the grips and X are "use", from the hand: the server finds what's in reach of that hand
if (PRESSED(ACT_USE_OFF)) sendButtonActionSimple("+use2");
if (RELEASED(ACT_USE_OFF)) sendButtonActionSimple("-use2");
if (!menu) {
if (PRESSED(ACT_ALTFIRE)) sendButtonActionSimple("+attack2");
if (RELEASED(ACT_ALTFIRE)) sendButtonActionSimple("-attack2");
if (PRESSED(ACT_RELOAD)) sendButtonActionSimple("+reload");
if (RELEASED(ACT_RELOAD)) sendButtonActionSimple("-reload");
if (PRESSED(ACT_PREV_WEAPON)) sendButtonActionSimple("invprev");
if (PRESSED(ACT_NEXT_WEAPON)) sendButtonActionSimple("invnext");
if (PRESSED(ACT_LAST_WEAPON)) sendButtonActionSimple("lastinv");
if (PRESSED(ACT_SCOREBOARD) && multiplayer) {
// the scoreboard only. The flat screen view is the vr_flat_screen setting, no button does it
scoreboardShown = !scoreboardShown;
sendButtonAction("+showscores", scoreboardShown);
}
if (quickSaveFromMenuHold && PRESSED(ACT_QUICKSAVE) && !multiplayer) {
sendButtonActionSimple("savequick");
TBXR_Vibrate(80, leftHanded ? 0 : 1, 0.6f);
}
} else {
// let go in a menu of what was held in the game, so nothing stays down
if (RELEASED(ACT_ALTFIRE)) sendButtonActionSimple("-attack2");
if (RELEASED(ACT_RELOAD)) sendButtonActionSimple("-reload");
static const struct { int action; int key; } keys[] = {
{ ACT_MENU_CONFIRM, K_ENTER }, { ACT_MENU_UP, K_UPARROW }, { ACT_MENU_DOWN, K_DOWNARROW },
{ ACT_MENU_LEFT, K_LEFTARROW }, { ACT_MENU_RIGHT, K_RIGHTARROW },
{ ACT_MENU_PAGE_PREV, K_PGUP }, { ACT_MENU_PAGE_NEXT, K_PGDN },
};
for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) {
if (PRESSED(keys[i].action)) Key_Event(keys[i].key, true);
if (RELEASED(keys[i].action)) Key_Event(keys[i].key, false);
}
if (PRESSED(ACT_MENU_SCROLL_UP)) { Key_Event(K_MWHEELUP, true); Key_Event(K_MWHEELUP, false); }
if (PRESSED(ACT_MENU_SCROLL_DOWN)) { Key_Event(K_MWHEELDOWN, true); Key_Event(K_MWHEELDOWN, false); }
}
previousActions[0] = actions[0];
previousActions[1] = actions[1];
#undef HAS
#undef PRESSED
#undef RELEASED
}
@@ -0,0 +1,21 @@
// Steam Frame controllers: what the runtime says about the physical buttons this frame, and
// the code that turns it into what the game reads. The map itself is VrFrameMap.c.
#ifndef VRINPUTFRAME_H
#define VRINPUTFRAME_H
#include <stdbool.h>
typedef struct {
float triggerLeft, triggerRight, gripLeft, gripRight;
bool bumperLeft, bumperRight;
bool stickClickLeft, stickClickRight;
bool a, b, x, y, menu, view;
bool dpadUp, dpadDown, dpadLeft, dpadRight;
float stickLeftX, stickLeftY, stickRightX, stickRightY;
} frameRaw_t;
// Called once a frame after the Quest-shaped controller states have been read, when the runtime
// is using the Frame's controller profile. Rewrites those states and sends the commands.
void VrInputFrame_Apply(const frameRaw_t* raw);
#endif
@@ -0,0 +1,153 @@
#include <math.h>
#include "VrPointerMath.h"
static void rotate(const float q[4], const float v[3], float out[3])
{
const float x = q[0], y = q[1], z = q[2], w = q[3];
const float cx = y * v[2] - z * v[1];
const float cy = z * v[0] - x * v[2];
const float cz = x * v[1] - y * v[0];
const float dx = y * cz - z * cy;
const float dy = z * cx - x * cz;
const float dz = x * cy - y * cx;
out[0] = v[0] + 2.0f * (w * cx + dx);
out[1] = v[1] + 2.0f * (w * cy + dy);
out[2] = v[2] + 2.0f * (w * cz + dz);
}
static float dot(const float a[3], const float b[3])
{
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
static void cross(const float a[3], const float b[3], float out[3])
{
out[0] = a[1] * b[2] - a[2] * b[1];
out[1] = a[2] * b[0] - a[0] * b[2];
out[2] = a[0] * b[1] - a[1] * b[0];
}
static bool normalise(float v[3])
{
const float length = sqrtf(dot(v, v));
if (length < 1e-6f)
return false;
v[0] /= length; v[1] /= length; v[2] /= length;
return true;
}
void VrPointer_Forward(const float pose[7], float out[3])
{
const float forward[3] = {0.0f, 0.0f, -1.0f};
rotate(pose + 3, forward, out);
}
bool VrPointer_RayQuad(const float origin[3], const float direction[3], const float quadPose[7],
float width, float height, float* u, float* v, float* t)
{
const float xAxis[3] = {1.0f, 0.0f, 0.0f}, yAxis[3] = {0.0f, 1.0f, 0.0f}, zAxis[3] = {0.0f, 0.0f, 1.0f};
float right[3], up[3], normal[3];
rotate(quadPose + 3, xAxis, right);
rotate(quadPose + 3, yAxis, up);
rotate(quadPose + 3, zAxis, normal);
float d[3] = {direction[0], direction[1], direction[2]};
if (!normalise(d))
return false;
const float facing = dot(d, normal);
if (facing > -1e-4f) // away from the quad's face, or along it
return false;
const float toQuad[3] = {quadPose[0] - origin[0], quadPose[1] - origin[1], quadPose[2] - origin[2]};
const float distance = dot(toQuad, normal) / facing;
if (distance <= 0.0f)
return false;
const float hit[3] = {origin[0] + d[0] * distance - quadPose[0], origin[1] + d[1] * distance - quadPose[1],
origin[2] + d[2] * distance - quadPose[2]};
*u = dot(hit, right) / width + 0.5f;
*v = 0.5f - dot(hit, up) / height;
*t = distance;
return true;
}
static void quaternionFromAxes(const float x[3], const float y[3], const float z[3], float q[4])
{
// the rotation whose columns are x, y, z
const float trace = x[0] + y[1] + z[2];
if (trace > 0.0f)
{
const float s = sqrtf(trace + 1.0f) * 2.0f;
q[3] = 0.25f * s;
q[0] = (y[2] - z[1]) / s;
q[1] = (z[0] - x[2]) / s;
q[2] = (x[1] - y[0]) / s;
}
else if (x[0] > y[1] && x[0] > z[2])
{
const float s = sqrtf(1.0f + x[0] - y[1] - z[2]) * 2.0f;
q[3] = (y[2] - z[1]) / s;
q[0] = 0.25f * s;
q[1] = (y[0] + x[1]) / s;
q[2] = (z[0] + x[2]) / s;
}
else if (y[1] > z[2])
{
const float s = sqrtf(1.0f + y[1] - x[0] - z[2]) * 2.0f;
q[3] = (z[0] - x[2]) / s;
q[0] = (y[0] + x[1]) / s;
q[1] = 0.25f * s;
q[2] = (z[1] + y[2]) / s;
}
else
{
const float s = sqrtf(1.0f + z[2] - x[0] - y[1]) * 2.0f;
q[3] = (x[1] - y[0]) / s;
q[0] = (z[0] + x[2]) / s;
q[1] = (z[1] + y[2]) / s;
q[2] = 0.25f * s;
}
}
float VrPointer_BeamPose(const float a[3], const float b[3], const float eye[3], float out[7])
{
float y[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
const float length = sqrtf(dot(y, y));
if (!normalise(y))
{
out[0] = a[0]; out[1] = a[1]; out[2] = a[2];
out[3] = out[4] = out[5] = 0.0f; out[6] = 1.0f;
return 0.0f;
}
const float mid[3] = {(a[0] + b[0]) * 0.5f, (a[1] + b[1]) * 0.5f, (a[2] + b[2]) * 0.5f};
float z[3] = {eye[0] - mid[0], eye[1] - mid[1], eye[2] - mid[2]};
const float along = dot(z, y);
z[0] -= y[0] * along; z[1] -= y[1] * along; z[2] -= y[2] * along;
if (!normalise(z))
{
// looking straight down the beam: any way round will do
const float other[3] = {fabsf(y[0]) < 0.9f ? 1.0f : 0.0f, fabsf(y[0]) < 0.9f ? 0.0f : 1.0f, 0.0f};
cross(y, other, z);
normalise(z);
}
float x[3];
cross(y, z, x);
out[0] = mid[0]; out[1] = mid[1]; out[2] = mid[2];
quaternionFromAxes(x, y, z, out + 3);
return length;
}
void VrPointer_DotPose(const float p[3], const float quadPose[7], float lift, float out[7])
{
const float zAxis[3] = {0.0f, 0.0f, 1.0f};
float normal[3];
rotate(quadPose + 3, zAxis, normal);
out[0] = p[0] + normal[0] * lift;
out[1] = p[1] + normal[1] * lift;
out[2] = p[2] + normal[2] * lift;
out[3] = quadPose[3]; out[4] = quadPose[4]; out[5] = quadPose[5]; out[6] = quadPose[6];
}
@@ -0,0 +1,27 @@
// Steam Frame: the menu laser. A ray from the weapon hand's aim pose against the flat screen
// quad, and the thin quads (the beam and its dot) that are drawn as layers of their own so nothing
// can cover them. Plain C with no OpenXR types, for the host tests. Poses are position x y z and
// quaternion x y z w (OpenXR axes: forward is -z, a quad faces +z).
#ifndef VRPOINTERMATH_H
#define VRPOINTERMATH_H
#include <stdbool.h>
// Where a ray from origin along direction (any length) meets the quad, from the quad's front. u and v
// are 0..1 across and down it (they can be outside that when the ray misses the quad itself), t is how
// far along the ray in metres of the (normalised) direction. False when the ray points away from the
// quad or along it.
bool VrPointer_RayQuad(const float origin[3], const float direction[3], const float quadPose[7],
float width, float height, float* u, float* v, float* t);
// The direction a pose's -z axis points in
void VrPointer_Forward(const float pose[7], float out[3]);
// The pose of a thin quad that lies along the line from a to b (its long side, the height, is
// the line's length) and turns about it to face the eye. Returns the length.
float VrPointer_BeamPose(const float a[3], const float b[3], const float eye[3], float out[7]);
// A pose at p turned like the quad (and pushed out of its face by lift, towards the viewer)
void VrPointer_DotPose(const float p[3], const float quadPose[7], float lift, float out[7]);
#endif
@@ -0,0 +1,8 @@
LOCAL_PATH := $(call my-dir)
# libxash links against the pinned Khronos loader in every Android build type.
# Gradle fetches this build-only binary before ndk-build runs.
include $(CLEAR_VARS)
LOCAL_MODULE := openxr_loader
LOCAL_SRC_FILES := ../../../build/openxr-loader/1.1.63/libopenxr_loader.so
include $(PREBUILT_SHARED_LIBRARY)
@@ -69,7 +69,11 @@ GNU General Public License for more details.
#define MENUDLL "libmenu" POSTFIX "." OS_LIB_EXT
#define CLIENTDLL "libclient" POSTFIX "." OS_LIB_EXT
#define SERVERDLL "libserver" POSTFIX "." OS_LIB_EXT
#ifdef L1VR_STEAM_FRAME
#define GAMEPATH "/sdcard/Documents/Lambda1VR"
#else
#define GAMEPATH "/sdcard/xash"
#endif
#elif defined(__SAILFISH__)
#define POSTFIX
// don't change these names
@@ -14,6 +14,12 @@ LOCAL_CFLAGS += -D__MULTITEXTURE_SUPPORT__ -DXASH_GLES -DXASH_GL4ES -DUSE_EVDEV
#enable VR compatibility
LOCAL_CFLAGS += -DVR
# Valve Steam Frame build (gradle assembleFrame, docs/STEAM_FRAME_VR.md)
ifeq ($(L1VR_STEAM_FRAME),1)
# the engine's messages go to logcat (tag Xash), but only with a developer level
LOCAL_CFLAGS += -DL1VR_STEAM_FRAME -DDEFAULT_DEV=3
endif
LOCAL_CONLYFLAGS += -std=c99
LOCAL_C_INCLUDES := \
@@ -180,6 +186,29 @@ LOCAL_SRC_FILES := \
platform/android/android_nosdl.c \
platform/android/dlsym-weak.cpp
# test switches for the Frame build, off unless vr-diag.ini is in the data folder
ifeq ($(L1VR_STEAM_FRAME),1)
LAMBDA1VR_SRC_FILES += \
$(LOCAL_PATH)/../../../Lambda1VR/VrDiag.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrDiagCore.c
LOCAL_SRC_FILES += $(LOCAL_PATH)/../../../Lambda1VR/VrDiag.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrDiagCore.c
endif
ifeq ($(L1VR_STEAM_FRAME),1)
LAMBDA1VR_SRC_FILES += $(LOCAL_PATH)/../../../Lambda1VR/VrEyeMath.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrFrameMap.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrPointerMath.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrInputFrame.c
LOCAL_SRC_FILES += $(LOCAL_PATH)/../../../Lambda1VR/VrEyeMath.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrFrameMap.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrPointerMath.c \
$(LOCAL_PATH)/../../../Lambda1VR/VrInputFrame.c
endif
# warnings for the VR layer (argtable3 isn't ours)
$(call add-src-files-target-cflags,$(filter-out %/argtable3.c,$(LAMBDA1VR_SRC_FILES)),-Wall)
LOCAL_LDLIBS := -ldl -llog -landroid -lGLESv3 -lEGL # include default libraries
LOCAL_SHARED_LIBRARIES := openxr_loader gl4es
@@ -1148,6 +1148,58 @@ static void CL_DrawPause( void )
R_DrawStretchPic( x, y, width, height, 0, 0, 1, 1, cls.pauseIcon );
}
#ifdef L1VR_STEAM_FRAME
extern convar_t *vr_stereo_side;
float VR_GetHudShift( int eye );
void R_SetHudShift( float pixels );
void R_SetHudRoll( float degrees );
float VR_GetHudRoll( int eye );
bool isScopeEngaged( void );
/*
===============
CL_RedrawHUD
The client DLL puts its HUD in front of the screen with a fixed sideways shift of width / 36
per eye, which it works out from vr_stereo_side. That's about 0.4 m on the Frame's lenses, and
tiring. While it draws, the eyes are given as the mono ones (that's 0 for it, and the 2D
offset onto the optical axis still works for those), and the whole HUD is shifted here
instead, to where vr_hud_distance says. A scope is mono already, so that is left alone.
===============
*/
static void CL_RedrawHUD( qboolean paused )
{
const int eye = vr_stereo_side->integer;
float shift;
if( eye < VR_EYE_LEFT || eye > VR_EYE_RIGHT || isScopeEngaged( ))
{
CL_DrawCenterPrint ();
clgame.dllFuncs.pfnRedraw( cl.time, cl.refdef.intermission );
if( paused ) CL_DrawPause();
return;
}
shift = VR_GetHudShift( eye );
vr_stereo_side->value = eye + 2;
vr_stereo_side->integer = eye + 2;
R_SetHudShift( shift );
R_SetHudRoll( VR_GetHudRoll( eye ));
R_Set2DMode( true );
CL_DrawCenterPrint ();
clgame.dllFuncs.pfnRedraw( cl.time, cl.refdef.intermission );
if( paused ) CL_DrawPause();
vr_stereo_side->value = eye;
vr_stereo_side->integer = eye;
R_SetHudShift( 0.0f );
R_SetHudRoll( 0.0f );
R_Set2DMode( true );
}
#endif
void CL_DrawHUD( int state )
{
if( state == CL_ACTIVE && !cl.video_prepped )
@@ -1161,15 +1213,23 @@ void CL_DrawHUD( int state )
case CL_ACTIVE:
CL_DrawScreenFade ();
CL_DrawCrosshair ();
#ifdef L1VR_STEAM_FRAME
CL_RedrawHUD ( false );
#else
CL_DrawCenterPrint ();
clgame.dllFuncs.pfnRedraw( cl.time, cl.refdef.intermission );
#endif
break;
case CL_PAUSED:
CL_DrawScreenFade ();
CL_DrawCrosshair ();
#ifdef L1VR_STEAM_FRAME
CL_RedrawHUD ( true );
#else
CL_DrawCenterPrint ();
clgame.dllFuncs.pfnRedraw( cl.time, cl.refdef.intermission );
CL_DrawPause();
#endif
break;
case CL_LOADING:
CL_DrawLoading( scr_loading->value );
@@ -43,11 +43,20 @@ typedef struct
static dirty_t scr_dirty, scr_old_dirty[2];
static qboolean scr_init = false;
#ifdef L1VR_STEAM_FRAME
float VR_GetHudShift( int eye );
#endif
int GetStereoDepthOffset()
{
if (vr_stereo_side->value >= VR_EYE_LEFT_MONO)
return 0;
#ifdef L1VR_STEAM_FRAME
// the same place the HUD is put
return (int)VR_GetHudShift( vr_stereo_side->integer );
#endif
return (int)( ( vr_stereo_side->value * -2.0f ) + 1.0f) * (scr_width->integer / 36.0f);
}
@@ -283,6 +283,23 @@ two are not the same point, so 2D content anchored to the buffer centre would no
the eyes. Shifting the ortho window here moves every 2D item at once.
===============
*/
#ifdef L1VR_STEAM_FRAME
// extra shift for the HUD pass (see CL_RedrawHUD), in pixels, positive to the right
static float r_hudShift = 0.0f;
// and how far to turn it, in degrees, about the optical axis (it stays level when the head rolls)
static float r_hudRoll = 0.0f;
void R_SetHudShift( float pixels )
{
r_hudShift = pixels;
}
void R_SetHudRoll( float degrees )
{
r_hudRoll = degrees;
}
#endif
void R_Get2DOffset( float *dx, float *dy )
{
*dx = *dy = 0.0f;
@@ -292,6 +309,9 @@ void R_Get2DOffset( float *dx, float *dy )
return;
VR_Get2DOffset( vr_stereo_side->integer, glState.width, glState.height, dx, dy );
#ifdef L1VR_STEAM_FRAME
*dx += r_hudShift;
#endif
}
/*
@@ -302,6 +322,9 @@ R_Set2DMode
void R_Set2DMode( qboolean enable )
{
static float last_dx = 0.0f, last_dy = 0.0f;
#ifdef L1VR_STEAM_FRAME
static float last_roll = 0.0f;
#endif
float dx, dy;
if( enable )
@@ -309,8 +332,14 @@ void R_Set2DMode( qboolean enable )
R_Get2DOffset( &dx, &dy );
// the offset flips sign between eyes, so a cached ortho from the other eye is stale
#ifdef L1VR_STEAM_FRAME
if( glState.in2DMode && dx == last_dx && dy == last_dy && r_hudRoll == last_roll )
return;
last_roll = r_hudRoll;
#else
if( glState.in2DMode && dx == last_dx && dy == last_dy )
return;
#endif
last_dx = dx;
last_dy = dy;
@@ -320,6 +349,16 @@ void R_Set2DMode( qboolean enable )
pglMatrixMode( GL_PROJECTION );
pglLoadIdentity();
pglOrtho( -dx, glState.width - dx, glState.height - dy, -dy, -99999, 99999 );
#ifdef L1VR_STEAM_FRAME
if( r_hudRoll != 0.0f )
{
// about the middle of the 2D space, which the offset above has put on the optical axis,
// before the HUD distance shift (that one is along the eyes, and so is along the image)
pglTranslatef( glState.width * 0.5f, glState.height * 0.5f, 0.0f );
pglRotatef( r_hudRoll, 0.0f, 0.0f, 1.0f );
pglTranslatef( -glState.width * 0.5f, -glState.height * 0.5f, 0.0f );
}
#endif
pglMatrixMode( GL_MODELVIEW );
pglLoadIdentity();
@@ -668,6 +668,9 @@ static void R_SetupProjectionMatrix( const ref_params_t *fd, matrix4x4 m )
}
#ifdef VR
#ifdef L1VR_STEAM_FRAME
bool VR_GetEyeTransform( int eye, float worldScale, vec3_t offset, float *cantDegrees, vec3_t cantAxis );
#endif
/*
=============
R_SetupModelviewMatrix
@@ -677,10 +680,24 @@ static void R_SetupModelviewMatrix( const ref_params_t *fd, matrix4x4 m )
{
Matrix4x4_CreateModelview( m );
#ifdef L1VR_STEAM_FRAME
// Each eye is where the runtime says it is, in the head's frame, turned the way
// it says too, instead of a fixed IPD along the side
if (vr_stereo_side->value == VR_EYE_LEFT || vr_stereo_side->value == VR_EYE_RIGHT) {
vec3_t eyeOffset, cantAxis;
float cantDegrees;
if (VR_GetEyeTransform(vr_stereo_side->integer, vr_worldscale->value, eyeOffset, &cantDegrees, cantAxis)) {
if (cantDegrees != 0.0f)
Matrix4x4_ConcatRotate(m, -cantDegrees, cantAxis[0], cantAxis[1], cantAxis[2]);
Matrix4x4_ConcatTranslate(m, -eyeOffset[0], -eyeOffset[1], -eyeOffset[2]);
}
}
#else
if (vr_stereo_side->value == VR_EYE_LEFT || vr_stereo_side->value == VR_EYE_RIGHT) {
Matrix4x4_ConcatTranslate(m, 0, vr_worldscale->value * (VR_IPD / 2.0f) *
((vr_stereo_side->integer - 0.5f) * 2.0f), 0);
}
#endif
Matrix4x4_ConcatRotate( m, -fd->viewangles[ROLL], 1, 0, 0 );
Matrix4x4_ConcatRotate( m, -fd->viewangles[PITCH], 0, 1, 0 );
@@ -912,6 +912,11 @@ static qboolean R_SpriteHasLightmap( cl_entity_t *e, int texFormat )
R_DrawSpriteModel
=================
*/
#ifdef L1VR_STEAM_FRAME
#include <stdbool.h>
bool VR_BillboardsLevel( void );
#endif
void R_DrawSpriteModel( cl_entity_t *e )
{
mspriteframe_t *frame, *oldframe;
@@ -921,6 +926,7 @@ void R_DrawSpriteModel( cl_entity_t *e )
float angle, dot, sr, cr, flAlpha;
float lerp = 1.0f, ilerp, scale;
vec3_t v_forward, v_right, v_up;
vec3_t view_right, view_up;
vec3_t origin, color, color2 = {0.0f};
if( RI.params & RP_ENVVIEW )
@@ -1024,6 +1030,21 @@ void R_DrawSpriteModel( cl_entity_t *e )
type = psprite->type;
// the way the sprite faces the view: the view's own right and up, or with the head's roll taken out
// on the Frame, so a sprite stays upright in the world when the head tilts
VectorCopy( RI.vright, view_right );
VectorCopy( RI.vup, view_up );
#ifdef L1VR_STEAM_FRAME
if( VR_BillboardsLevel( ))
{
vec3_t level_angles, level_forward;
VectorCopy( RI.refdef.viewangles, level_angles );
level_angles[ROLL] = 0.0f;
AngleVectors( level_angles, level_forward, view_right, view_up );
}
#endif
// automatically roll parallel sprites if requested
if( e->angles[ROLL] != 0.0f && type == SPR_FWD_PARALLEL )
type = SPR_FWD_PARALLEL_ORIENTED;
@@ -1053,14 +1074,14 @@ void R_DrawSpriteModel( cl_entity_t *e )
SinCos( angle, &sr, &cr );
for( i = 0; i < 3; i++ )
{
v_right[i] = (RI.vright[i] * cr + RI.vup[i] * sr);
v_up[i] = RI.vright[i] * -sr + RI.vup[i] * cr;
v_right[i] = (view_right[i] * cr + view_up[i] * sr);
v_up[i] = view_right[i] * -sr + view_up[i] * cr;
}
break;
case SPR_FWD_PARALLEL: // normal sprite
default:
VectorCopy( RI.vright, v_right );
VectorCopy( RI.vup, v_up );
VectorCopy( view_right, v_right );
VectorCopy( view_up, v_up );
break;
}
@@ -14,6 +14,11 @@ endif
LOCAL_MODULE := menu
LOCAL_CPPFLAGS := -std=gnu++11 -DMAINUI_USE_STB -DMAINUI_USE_CUSTOM_FONT_RENDER -DNO_STL -fno-rtti -fno-exceptions -DVR
# Valve Steam Frame build (gradle assembleFrame): the options that only it has
ifeq ($(L1VR_STEAM_FRAME),1)
LOCAL_CPPFLAGS += -DL1VR_STEAM_FRAME
endif
LOCAL_C_INCLUDES := \
$(SDL_PATH)/include \
$(LOCAL_PATH)/. \
@@ -27,6 +27,7 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#include "Table.h"
#include "Action.h"
#include "YesNoMessageBox.h"
#include "HostGameOptions.h"
#include "Table.h"
#define ART_BANNER "gfx/shell/head_creategame"
@@ -82,6 +83,17 @@ private:
void _VidInit() override;
};
static void NormalizeMaxClientsField( CMenuField *field, bool preserveEmpty )
{
const char *value = field->GetBuffer();
if( preserveEmpty && !value[0] )
return;
char normalized[4];
snprintf( normalized, sizeof( normalized ), "%d", HostGame_NormalizeMaxPlayers( atoi( value ) ));
field->SetBuffer( normalized );
}
static CMenuCreateGame uiCreateGame;
/*
@@ -124,7 +136,9 @@ void CMenuCreateGame::Begin( CMenuBaseItem *pSelf, void *pExtra )
menu->password.WriteCvar();
menu->hostName.WriteCvar();
menu->hltv.WriteCvar();
NormalizeMaxClientsField( &menu->maxClients, false );
menu->maxClients.WriteCvar();
const int maxPlayers = HostGame_NormalizeMaxPlayers( atoi( menu->maxClients.GetBuffer() ));
EngFuncs::PlayBackgroundTrack( NULL, NULL );
@@ -150,12 +164,12 @@ void CMenuCreateGame::Begin( CMenuBaseItem *pSelf, void *pExtra )
EngFuncs::ClientCmd( TRUE, cmd );
// dirty listenserver config form old xash may rewrite maxplayers
EngFuncs::CvarSetValue( "maxplayers", atoi( menu->maxClients.GetBuffer() ));
EngFuncs::CvarSetValue( "maxplayers", maxPlayers );
Com_EscapeCommand( cmd2, mapName, 256 );
// hack: wait three frames allowing server to completely shutdown, reapply maxplayers and start new map
sprintf( cmd, "endgame;menu_connectionprogress localserver;wait;wait;wait;maxplayers %i;latch;map %s\n", atoi( menu->maxClients.GetBuffer() ), cmd2 );
sprintf( cmd, "endgame;menu_connectionprogress localserver;wait;wait;wait;maxplayers %i;latch;map %s\n", maxPlayers, cmd2 );
EngFuncs::ClientCmd( FALSE, cmd );
}
@@ -248,26 +262,17 @@ void CMenuCreateGame::_Init( void )
maxClients.bNumbersOnly = true;
maxClients.szName = "Max Players:";
maxClients.LinkCvar( "maxplayers" );
NormalizeMaxClientsField( &maxClients, false );
SET_EVENT_MULTI( maxClients.onCvarChange,
{
CMenuField *self = (CMenuField*)pSelf;
if( !self->GetBuffer()[0] )
return;
int players = atoi( self->GetBuffer() );
if( players <= 1 )
self->SetBuffer( "2" );
else if( players > 32 )
self->SetBuffer( "32" );
NormalizeMaxClientsField( self, true );
});
SET_EVENT_MULTI( maxClients.onLostFocus,
{
CMenuField *self = (CMenuField*)pSelf;
int players = atoi( self->GetBuffer() );
if( players <= 1 )
self->SetBuffer( "2" );
else if( players > 32 )
self->SetBuffer( "32" );
NormalizeMaxClientsField( self, false );
});
password.szName = "Password:";
@@ -0,0 +1,12 @@
#ifndef XASH_HOST_GAME_OPTIONS_H
#define XASH_HOST_GAME_OPTIONS_H
// A listen server needs a slot for its local player and at least one remote player.
static inline int HostGame_NormalizeMaxPlayers( int players )
{
if( players < 2 ) return 2;
if( players > 32 ) return 32;
return players;
}
#endif // XASH_HOST_GAME_OPTIONS_H
@@ -66,6 +66,9 @@ public:
CMenuCheckBox vbo;
CMenuCheckBox actionables;
CMenuCheckBox fps;
#ifdef L1VR_STEAM_FRAME
CMenuCheckBox flatScreen;
#endif
CMenuSlider height;
CMenuSlider vignette;
@@ -104,6 +107,9 @@ void CMenuVidOptions::SaveAndPopMenu( void )
vbo.WriteCvar();
actionables.WriteCvar();
fps.WriteCvar();
#ifdef L1VR_STEAM_FRAME
flatScreen.WriteCvar();
#endif
height.WriteCvar();
vignette.WriteCvar();
// gamma is already written
@@ -240,8 +246,19 @@ void CMenuVidOptions::_Init( void )
fps.SetCoord( 72, 705 );
fps.LinkCvar( "cl_showfps" );
#ifdef L1VR_STEAM_FRAME
// Steam Frame build only: the game on a flat screen in front of you, it has no button
flatScreen.SetNameAndStatus( "Flat Screen View", "Show the game on a flat screen in front of you. The controllers only point and click then" );
flatScreen.SetCoord( 72, 755 );
flatScreen.LinkCvar( "vr_flat_screen" );
#endif
done.SetNameAndStatus( "Done", "Go back to the Video Menu" );
#ifdef L1VR_STEAM_FRAME
done.SetCoord( 72, 805 );
#else
done.SetCoord( 72, 755 );
#endif
done.SetPicture( PC_DONE );
done.onActivated = VoidCb( &CMenuVidOptions::SaveAndPopMenu );
@@ -253,6 +270,9 @@ void CMenuVidOptions::_Init( void )
AddItem( refresh );
AddItem( actionables );
AddItem( fps );
#ifdef L1VR_STEAM_FRAME
AddItem( flatScreen );
#endif
AddItem( vbo );
AddItem( height );
AddItem( vignette );
-8
View File
@@ -1,8 +0,0 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Dec 12 20:21:11 GMT 2019
sdk.dir=C\:\\Users\\Simon\\AppData\\Local\\Android\\Sdk
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Merged over the main manifest for the frame build type (Valve Steam Frame).
Its Android container (Lepton) doesn't advertise android.hardware.vr.headtracking,
so the install would fail with it required. The Quest and Pico shell metadata
doesn't do anything there either. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.vr.headtracking"
tools:node="remove" />
<application>
<meta-data android:name="com.samsung.android.vr.application.mode" tools:node="remove" />
<meta-data android:name="com.oculus.supportedDevices" tools:node="remove" />
<meta-data android:name="com.oculus.dualcorecpuset" tools:node="remove" />
<meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" tools:node="remove" />
<meta-data android:name="com.oculus.vr.focusaware" tools:node="remove" />
<meta-data android:name="pvr.app.type" tools:node="remove" />
</application>
</manifest>
+8 -1
View File
@@ -4,10 +4,17 @@ include $(CLEAR_VARS)
LOCAL_MODULE := openxr_loader
# The Steam Frame build passes the Khronos loader it fetched
ifneq ($(L1VR_OPENXR_LOADER),)
LOCAL_SRC_FILES := $(L1VR_OPENXR_LOADER)
LOADER_FILE := $(L1VR_OPENXR_LOADER)
else
LOCAL_SRC_FILES := lib$(LOCAL_MODULE).so
LOADER_FILE := $(LOCAL_PATH)/$(LOCAL_SRC_FILES)
endif
# NOTE: This check is added to prevent the following error when running a "make clean" where
# the prebuilt lib may have been deleted: "LOCAL_SRC_FILES points to a missing file"
ifneq (,$(wildcard $(LOCAL_PATH)/$(LOCAL_SRC_FILES)))
ifneq (,$(wildcard $(LOADER_FILE)))
include $(PREBUILT_SHARED_LIBRARY)
endif
+30 -171
View File
@@ -1,179 +1,38 @@
![Lambda1VR Banner](https://www.lambda1vr.com/img/banner2.jpg)
# Lambda1VR on Steam Frame
# Lambda1VR
This is my Steam Frame build of [Team Beef's Lambda1VR](https://github.com/Team-Beef-Studios/Lambda1VR), the Xash3D-FWGS and GoldSrc-based Half-Life VR port. It runs in the Frame's Lepton Android container with ARM64, OpenXR and GLES. See the [architecture notes](docs/VR_ARCHITECTURE.md) for the input and rendering details.
Lambda1VR is a port / mod of the Xash3D-FWGS Half-Life Engine for the Oculus Quest adding 6DoF weapon support and OpenGLES2.0 rendering.
**EXPERIMENTAL.** I made this for personal use. This is a source release of my Frame changes to Team Beef's existing port, with the original GPLv3 and component notices kept. See the [build and publication notes](docs/PR0_READINESS.md). The [original README](docs/README_PRE_PR0.md) is preserved.
```
Xash3D is a game engine, aimed to provide compatibility with Half-Life Engine,
as well as to give game developers well known workflow and extend it.
Read more about Xash3D on ModDB: https://www.moddb.com/engines/xash3d-engine
The Frame build keeps tracked head and hands, stereo rendering, Steam Frame controller bindings, a laser pointer for menus, snap or smooth turning, and 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, including Barney's vest and helmet and the scope sprite, are known gaps.
## Half-Life data
You need the full retail Half-Life game data from your own copy. The app can read Half-Life installed through Steam on the Frame, or a `valve/` folder under `/sdcard/Documents/Lambda1VR/valve` (`~/Documents/Lambda1VR/valve` on SteamOS). Half-Life: Source is not compatible. Saves and settings live in the same Documents folder. The bundled `extras.pak` comes unchanged from Team Beef's public source; it doesn't replace the required retail game files.
## Build and install
Build on x86-64 Linux or macOS, or on Windows through WSL2 with an x86-64 Ubuntu distribution. These instructions
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.
Clone the public Frame branch and its submodules:
```sh
mkdir -p ~/src
cd ~/src
git clone --recurse-submodules --branch frame-dev https://github.com/astromaddie/lambda1-vr.git
cd lambda1-vr
```
### SideQuest
The easiest way to install this on your Quest is using SideQuest, a PC desktop app designed to simplify sideloading apps and games ( even beat saber songs on quest ) on Standalone Android Headsets like Oculus Quest and Oculus Go. It supports drag and drop for installing APK files!
Use JDK 17 and the Android SDK packages listed in the [Steam Frame guide](docs/STEAM_FRAME_VR.md). Before the first
build, restore the release assets the repository does not track, then build the Frame variant:
Download the latest version of SideQuest here:
https://github.com/the-expanse/SideQuest/releases
Once you have installed Lambda1VR you will need to copy the game assets from a legally owned half-life install. Details below.
## IMPORTANT NOTE
This is solely an engine port/mod, no game assets are included whatsoever. YOU *MUST* LEGALLY OWN HALF-LIFE IF YOU WISH TO PLAY IT IN VR USING LAMBDA1VR.
The original half-life can be purchased on Steam: https://store.steampowered.com/app/70/HalfLife/
*PLEASE NOTE:* Half-life: Source is *not* compatible.
## Copying the Full Game files to your Oculus Quest
Before attempting to start Lambda1VR you need to do the following:
- Install and start the application once until it shows the menu screen. At this point exit and you will find that a "xash" folder has been created on your headset.
- Locate the install of half-life on your PC, if installed from Steam then it will be somewhere like C:/Program Files (x86)/Steam/steamApps/common/HalfLife/
- Copy the content of the "valve" folder to the newly created "xash/valve" folder on the Quest (This will take a long time)
- (optional) Copy the contents of the valve_hd folder into the valve folder now on your Quest (this gives you some nicer models). If you are a Mac user please read this post, otherwise you will get a crash: https://www.reddit.com/r/Lambda1VR/comments/cls57m/mac_running_halflife_with_hd_assets_on_quest/
## Save Games File
Save game files are stored on the internal memory of your quest in the xash/valve folder. This means that if you uninstall/reinstall Lambda1VR all the saves are retained. If you change headset just make sure you copy {quest}/xash/valve/save to the new Quest.
Any update will not affect any of your save game files.
## Controls
Before you ask... YES!, the crowbar has to be swung in real life to smash stuff! (except in multi-player, you still have to press the trigger I'm afraid).
### Left-handed people
Add the following cvar to your config.cfg file: vr_control_scheme "10"
If left handed players wish to mirror the weapons to avoid seeing the gaps in some of the weapon models then set the following cvar in config.cfg: vr_mirror_weapons "1"
### Button / Controller Mappings (Primary configuration)
#### "BackPack"
You can get quick access to the crowbar by reaching behind (so the controller is obscured) and then holding down the grip trigger, this will then auto-switch to the crowbar whilst the grip trigger remains held down. As soon as you release the grip you will switch back to the weapon you had selected previously. This is an excellent way to quickly smash boxes/vents etc without the hassle of cycling through the weapon selector for the crowbar.
#### Mappings
This is the primary/default button mapping, an alternative configuration that might suit smooth turn locomotion players will be coming soon..
|Dominant Hand Controller||
| --- | --- |
|Orientation/Position|Weapon Orientation/Position|
|Trigger|Fire Primary / Secondary|
|Grip (Hold)|Enable secondary fire|
|Grip (Quick Click)|Reload|
|Click Thumbstick|Use (action)|
|Thumbstick Left/Right|Turn|
|Thumbstick Up/Down|Select Weapon (Fire to accept new weapon)|
|A / X (depending on dominant hand controller)|Crouch*|
|B / Y (depending on dominant hand controller)|Jump|
\* If you physically crouch in real life, then when the proper duck mechanic is triggered in-game you will see a HUD indicator in the top-right of the view
|Off-hand Controller||
| --- | --- |
|Thumbstick|Move/Strafe|
|Menu Button|Menu|
|X / A (depending on off-hand controller)|Flashlight on/off|
|Y / B (depending on off-hand controller)|Toggle screen mode for cut-scenes (Show multi-player score table in multi-player games)|
|Orientation/Position|Flashlight Orientation/Position|
|Trigger|Hold down to run)|
|Grip (hold)|Weapon Stabilise (hold to remain active)*|
|Click Thumbstick|Enable/Disable Laser Aim|
\* Weapon stabilisation has a deadzone when the two controllers are less than 15cm from each other to prevent screwing up two-handed aiming with the Glock etc
## Command Line Parameters
It is possible to supply command line parameter to the xash engine (and the Lambda1VR wrapper) using the commandline.txt file. The following command line parameters are supported by Lambda1VR (for a list of command line params supported by the Xash3D engine please see the Xash3D documentation):
for example you might use:
```
xash3d -log --supersampling 1.5 --msaa 2
```sh
bash tools/steam_frame/fetch_release_assets.sh
cd Projects/Android
sh ./gradlew assembleFrame
```
Which will set super sampling to 1.5 and msaa to 2.
|Parameter|Values|Desription|
| --- | --- | --- |
|supersampling|0.1 upwards (default: 1.25)|Sets the super (or under) sampling value|
|msaa|1, 2, 4 (default: 4)|Sets the MSAA anti-aliasing value|
|cpu|1-4 (default: 2)|Sets the CPU scaler|
|gpu|1-4 (default: 3)|Sets the GPU scaler|
## Things to note / FAQs
* Your screen name (for multi-player) can be set in the config.cfg file
* FPS counter can be enabled in the Video Options menu
* Weapon recoil is disabled by default.. if you want a more authentic experience it can be enabled with the cvar: vr_weapon_recoil 1
* If the player height is messed up. Try reseting the view by holding the home button for 2 seconds. After that go into the main menu and back into the game.
* After changing files in xash/vale folder, often the device needs to be restarted for the game to use the changed files.
## Known Issues
* Application crashes when you complete the Hazard Course
* No way to enter text
* Positional tracking doesn't really work on multi-player servers (unless you run your own server and set the following cvar: sv_accelerate 10000), it sort of works if you hold down the "run" trigger, but it isn't very good
* Lambda1VR will crash if you haven't copied the half-life game assets to the right location, no warning, just crash
* Complaint when you exit that the app did not shutdown properly (or crashed or something).. since you are quitting this wasn't considered serious enough to hold up the release, but will be fixed in a future update
* The edges of some textures are still corrupting when the flashlight shines on them
## Future To-Dos
* Text entry / Virtual Keyboard - Frustratingly the Quest virtual keyboard isn't available through the native SDK :(
## Building
You need the following:
* Android Developer Studio
* Android SDK API level 24
* Latest Android Native Development Kit
* Oculus Mobile SDK 1.24.0
* The Lambda1VR folder should be below VrSamples in the extracted SDK
* Create a local.properties file in the root of the extracted Oculus Mobile SDK that contains the ndk.dir and sdk.dir properties for where your SDK/NDK are located (see Gradle documentation regarding this)
* To build debug you will need a _android.debug.keystore_ file placed in the following folder:
_oculus_sdk_dir_/VrSamples/Lambda1VR/Projects/Android
Alternatively you can use the docker image created by BrainSlugs83 which can be found here: https://github.com/BrainSlugs83/DockerOvrSdk
## Credits
I would like to thank the following teams and individual for making this possible:
* Baggyg - My long-time VR friend whose roles in this have been varied and all helpful, also the creator of excellent websites/artwork/assets for this mod and more recently becoming something of a MilkShape guru
* The Xash3D team / Uncle Mike
* GLE4ES without which this wouldn't have worked at all: https://github.com/ptitSeb/gl4es
* Max Vollmer for their 2017 Half-Life VR mod, of which I used and modified the 6DoF weapons code for this mod, the original code for which can be seen here: https://github.com/maxvollmer/Half-Life-VR
* The SideQuest team - For making it easy for people to install this
* https://github.com/formicsapien for the weapon models that were used in the first couple of releases
* Please go to https://www.lambda1vr.com/credits.html for a full list of credits for weapon models used in the current version
## Notice of Non-Affiliation and Disclaimers
Lambda1VR is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Valve Corporation, or any of its subsidiaries or its affiliates.
Lambda1VR is an unofficial port of the Xash3D-FWGS engine, which was originally written by Uncle Mike as a fully compatible open-source GoldSrc engine alternative, more details on that extraordinary project can be found here: https://www.moddb.com/engines/xash3d-engine
However this port (Lambda1VR) is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Xash3D team.
This port was developed using the now deprecated Xash3D port found here: https://github.com/FWGS/xash3d
## Licensing
Lambda1VR is covered by the GPL 3.0 license which is inherited from the Xash3D-FWGS project.
The HLSDK sub-repository and associated works (which include the original 6DoF weapons code by Max Vollmer) are in a separate repository wholly covered with the Valve Corporation Half Life 1 SDK LICENSE (https://github.com/DrBeef/hlsdk-xash3d).
The APK is `Projects/Android/build/outputs/apk/frame/lambda1vr-frame.apk`. Building creates the APK; it does not
install or launch it. The guide covers the first upload, later updates, and your Half-Life data.
+32
View File
@@ -0,0 +1,32 @@
{
"schema_version": 1,
"milestone": "PR0",
"status": "EXPERIMENTAL",
"publication_status": "public experimental source; no binary release",
"audited_commit": "f3773cb04d004a8d51ae095f0fac8c383d6bbafc",
"current_checkout_commit": "b5f1779386801d997cb1afef02bff875d80d61db",
"branch": "frame-dev",
"tested_commit": "b5f1779386801d997cb1afef02bff875d80d61db",
"supported_targets": [
"Steam Frame / Lepton Android 11 / ARM64 / SteamVR OpenXR / GLES 3.2"
],
"validation": {
"source_commit": "b5f1779386801d997cb1afef02bff875d80d61db",
"commands": [
"bash tools/steam_frame/tests/run_tests.sh"
],
"result": "PASS: three binding profiles and 34 actions, LAN host options, diagnostics/BMP, eye math, pointer math, frame mapping and deploy lock safety. No device install or launch."
},
"full_application_build": "not run for current audited source",
"distribution_blockers": [],
"data_boundary": "A user-provided Half-Life installation is required. Half-Life: Source is incompatible.",
"changelog": "CHANGELOG.md",
"audit_date": "2026-10-11",
"audit_details": "docs/PR0_READINESS.md",
"distribution_notes": [
"Root GPLv3 and original engine/SDK notices retained. Retail Half-Life data is supplied separately by the user.",
"assets/extras.pak is unchanged inherited Team Beef material, present in the public v1.7.4 source lineage at 99a0d62. The shared Android debug key is development build material.",
"Machine-specific local.properties files are removed from the tracked tip and ignored; historical upstream configuration remains in Git history."
],
"publication_date": "2026-10-11"
}
+19
View File
@@ -0,0 +1,19 @@
# PR0 — Lambda1VR on Steam Frame
**EXPERIMENTAL.** Source release of the Steam Frame fork of [Team Beef's Lambda1VR](https://github.com/Team-Beef-Studios/Lambda1VR). See the [README](../README.md) for features, controls, required game data and build instructions.
Target: Steam Frame / Lepton Android 11 / ARM64 / SteamVR OpenXR / GLES 3.2.
| Record | Revision |
| --- | --- |
| Earlier hygiene audit | `f3773cb04d004a8d51ae095f0fac8c383d6bbafc` |
| Host-tested source | `b5f1779386801d997cb1afef02bff875d80d61db` |
| Public upstream release lineage | Team Beef v1.7.4 at `99a0d62` |
`bash tools/steam_frame/tests/run_tests.sh` passed: bindings, LAN host options, diagnostics/BMP, eye and pointer math, controller mapping and deploy lock safety. No fresh full APK build was run for this publication update.
The root GPLv3 and engine/SDK notices are retained. `assets/extras.pak` matches inherited Team Beef source, and the shared Android debug key is development build material. Machine-specific `local.properties` files are removed from the tracked tip and ignored; the old upstream configuration remains in history.
Supply the retail Half-Life files from your own copy. Half-Life: Source is incompatible. The source license does not grant redistribution rights to the commercial game. No APK or game-data package is published here.
[Exact metadata](PR0_METADATA.json) · [Changelog](../CHANGELOG.md)
+181
View File
@@ -0,0 +1,181 @@
![Lambda1VR Banner](https://www.lambda1vr.com/img/banner2.jpg)
# Lambda1VR
Lambda1VR is a port / mod of the Xash3D-FWGS Half-Life Engine for the Oculus Quest adding 6DoF weapon support and OpenGLES2.0 rendering.
```
Xash3D is a game engine, aimed to provide compatibility with Half-Life Engine,
as well as to give game developers well known workflow and extend it.
Read more about Xash3D on ModDB: https://www.moddb.com/engines/xash3d-engine
```
There's a build for Valve's Steam Frame too, see [docs/STEAM_FRAME_VR.md](docs/STEAM_FRAME_VR.md).
### SideQuest
The easiest way to install this on your Quest is using SideQuest, a PC desktop app designed to simplify sideloading apps and games ( even beat saber songs on quest ) on Standalone Android Headsets like Oculus Quest and Oculus Go. It supports drag and drop for installing APK files!
Download the latest version of SideQuest here:
https://github.com/the-expanse/SideQuest/releases
Once you have installed Lambda1VR you will need to copy the game assets from a legally owned half-life install. Details below.
## IMPORTANT NOTE
This is solely an engine port/mod, no game assets are included whatsoever. YOU *MUST* LEGALLY OWN HALF-LIFE IF YOU WISH TO PLAY IT IN VR USING LAMBDA1VR.
The original half-life can be purchased on Steam: https://store.steampowered.com/app/70/HalfLife/
*PLEASE NOTE:* Half-life: Source is *not* compatible.
## Copying the Full Game files to your Oculus Quest
Before attempting to start Lambda1VR you need to do the following:
- Install and start the application once until it shows the menu screen. At this point exit and you will find that a "xash" folder has been created on your headset.
- Locate the install of half-life on your PC, if installed from Steam then it will be somewhere like C:/Program Files (x86)/Steam/steamApps/common/HalfLife/
- Copy the content of the "valve" folder to the newly created "xash/valve" folder on the Quest (This will take a long time)
- (optional) Copy the contents of the valve_hd folder into the valve folder now on your Quest (this gives you some nicer models). If you are a Mac user please read this post, otherwise you will get a crash: https://www.reddit.com/r/Lambda1VR/comments/cls57m/mac_running_halflife_with_hd_assets_on_quest/
## Save Games File
Save game files are stored on the internal memory of your quest in the xash/valve folder. This means that if you uninstall/reinstall Lambda1VR all the saves are retained. If you change headset just make sure you copy {quest}/xash/valve/save to the new Quest.
Any update will not affect any of your save game files.
## Controls
Before you ask... YES!, the crowbar has to be swung in real life to smash stuff! (except in multi-player, you still have to press the trigger I'm afraid).
### Left-handed people
Add the following cvar to your config.cfg file: vr_control_scheme "10"
If left handed players wish to mirror the weapons to avoid seeing the gaps in some of the weapon models then set the following cvar in config.cfg: vr_mirror_weapons "1"
### Button / Controller Mappings (Primary configuration)
#### "BackPack"
You can get quick access to the crowbar by reaching behind (so the controller is obscured) and then holding down the grip trigger, this will then auto-switch to the crowbar whilst the grip trigger remains held down. As soon as you release the grip you will switch back to the weapon you had selected previously. This is an excellent way to quickly smash boxes/vents etc without the hassle of cycling through the weapon selector for the crowbar.
#### Mappings
This is the primary/default button mapping, an alternative configuration that might suit smooth turn locomotion players will be coming soon..
|Dominant Hand Controller||
| --- | --- |
|Orientation/Position|Weapon Orientation/Position|
|Trigger|Fire Primary / Secondary|
|Grip (Hold)|Enable secondary fire|
|Grip (Quick Click)|Reload|
|Click Thumbstick|Use (action)|
|Thumbstick Left/Right|Turn|
|Thumbstick Up/Down|Select Weapon (Fire to accept new weapon)|
|A / X (depending on dominant hand controller)|Crouch*|
|B / Y (depending on dominant hand controller)|Jump|
\* If you physically crouch in real life, then when the proper duck mechanic is triggered in-game you will see a HUD indicator in the top-right of the view
|Off-hand Controller||
| --- | --- |
|Thumbstick|Move/Strafe|
|Menu Button|Menu|
|X / A (depending on off-hand controller)|Flashlight on/off|
|Y / B (depending on off-hand controller)|Toggle screen mode for cut-scenes (Show multi-player score table in multi-player games)|
|Orientation/Position|Flashlight Orientation/Position|
|Trigger|Hold down to run)|
|Grip (hold)|Weapon Stabilise (hold to remain active)*|
|Click Thumbstick|Enable/Disable Laser Aim|
\* Weapon stabilisation has a deadzone when the two controllers are less than 15cm from each other to prevent screwing up two-handed aiming with the Glock etc
## Command Line Parameters
It is possible to supply command line parameter to the xash engine (and the Lambda1VR wrapper) using the commandline.txt file. The following command line parameters are supported by Lambda1VR (for a list of command line params supported by the Xash3D engine please see the Xash3D documentation):
for example you might use:
```
xash3d -log --supersampling 1.5 --msaa 2
```
Which will set super sampling to 1.5 and msaa to 2.
|Parameter|Values|Desription|
| --- | --- | --- |
|supersampling|0.1 upwards (default: 1.25)|Sets the super (or under) sampling value|
|msaa|1, 2, 4 (default: 4)|Sets the MSAA anti-aliasing value|
|cpu|1-4 (default: 2)|Sets the CPU scaler|
|gpu|1-4 (default: 3)|Sets the GPU scaler|
## Things to note / FAQs
* Your screen name (for multi-player) can be set in the config.cfg file
* FPS counter can be enabled in the Video Options menu
* Weapon recoil is disabled by default.. if you want a more authentic experience it can be enabled with the cvar: vr_weapon_recoil 1
* If the player height is messed up. Try reseting the view by holding the home button for 2 seconds. After that go into the main menu and back into the game.
* After changing files in xash/vale folder, often the device needs to be restarted for the game to use the changed files.
## Known Issues
* Application crashes when you complete the Hazard Course
* No way to enter text
* Positional tracking doesn't really work on multi-player servers (unless you run your own server and set the following cvar: sv_accelerate 10000), it sort of works if you hold down the "run" trigger, but it isn't very good
* Lambda1VR will crash if you haven't copied the half-life game assets to the right location, no warning, just crash
* Complaint when you exit that the app did not shutdown properly (or crashed or something).. since you are quitting this wasn't considered serious enough to hold up the release, but will be fixed in a future update
* The edges of some textures are still corrupting when the flashlight shines on them
## Future To-Dos
* Text entry / Virtual Keyboard - Frustratingly the Quest virtual keyboard isn't available through the native SDK :(
## Building
You need the following:
* Android Developer Studio
* Android SDK API level 24
* Latest Android Native Development Kit
* Oculus Mobile SDK 1.24.0
* The Lambda1VR folder should be below VrSamples in the extracted SDK
* Create a local.properties file in the root of the extracted Oculus Mobile SDK that contains the ndk.dir and sdk.dir properties for where your SDK/NDK are located (see Gradle documentation regarding this)
* To build debug you will need a _android.debug.keystore_ file placed in the following folder:
_oculus_sdk_dir_/VrSamples/Lambda1VR/Projects/Android
Alternatively you can use the docker image created by BrainSlugs83 which can be found here: https://github.com/BrainSlugs83/DockerOvrSdk
## Credits
I would like to thank the following teams and individual for making this possible:
* Baggyg - My long-time VR friend whose roles in this have been varied and all helpful, also the creator of excellent websites/artwork/assets for this mod and more recently becoming something of a MilkShape guru
* The Xash3D team / Uncle Mike
* GLE4ES without which this wouldn't have worked at all: https://github.com/ptitSeb/gl4es
* Max Vollmer for their 2017 Half-Life VR mod, of which I used and modified the 6DoF weapons code for this mod, the original code for which can be seen here: https://github.com/maxvollmer/Half-Life-VR
* The SideQuest team - For making it easy for people to install this
* https://github.com/formicsapien for the weapon models that were used in the first couple of releases
* Please go to https://www.lambda1vr.com/credits.html for a full list of credits for weapon models used in the current version
## Notice of Non-Affiliation and Disclaimers
Lambda1VR is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Valve Corporation, or any of its subsidiaries or its affiliates.
Lambda1VR is an unofficial port of the Xash3D-FWGS engine, which was originally written by Uncle Mike as a fully compatible open-source GoldSrc engine alternative, more details on that extraordinary project can be found here: https://www.moddb.com/engines/xash3d-engine
However this port (Lambda1VR) is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Xash3D team.
This port was developed using the now deprecated Xash3D port found here: https://github.com/FWGS/xash3d
## Licensing
Lambda1VR is covered by the GPL 3.0 license which is inherited from the Xash3D-FWGS project.
The HLSDK sub-repository and associated works (which include the original 6DoF weapons code by Max Vollmer) are in a separate repository wholly covered with the Valve Corporation Half Life 1 SDK LICENSE (https://github.com/DrBeef/hlsdk-xash3d).
+269
View File
@@ -0,0 +1,269 @@
# 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](../README.md) 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](https://developer.android.com/ndk/downloads) list the available host
packages.
### Windows with WSL2
Install [WSL 2 and Ubuntu](https://learn.microsoft.com/en-us/windows/wsl/install). From PowerShell run as
Administrator, install Ubuntu with:
```powershell
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:
```bash
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](https://developer.android.com/studio)
and [`sdkmanager` pages](https://developer.android.com/tools/sdkmanager). Keep this Ubuntu terminal open so the
environment variables remain set.
Clone the public Frame branch and its submodules into the Linux home directory:
```bash
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):
```bash
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:
```bash
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](https://developer.android.com/studio)
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
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:
```bash
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](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames) 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](https://partner.steamgames.com/doc/steamhardware/steamframe/setup).
Valve's [debugging guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) 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:
```bash
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.
+306
View File
@@ -0,0 +1,306 @@
# Lambda1VR on the Steam Frame: how it fits together
This document describes the Frame build (`assembleFrame`, `L1VR_STEAM_FRAME`). The public setup and controls are in [STEAM_FRAME_VR.md](STEAM_FRAME_VR.md); this file maps the rendering, input and Android build changes. Known gameplay and asset gaps are called out where they occur below.
## 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; 120 for the Lambda1VR Steam title, `steam.app.4086534518` in steamvr.vrsettings); `XR_FB_display_refresh_rate` offers only 90 and the app can't ask for more. Only a Steam title launch gets the per-app rate |
| 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.
1. `TBXR_InitialiseOpenXR`: EGL context (pbuffer), `xrInitializeLoaderKHR`, instance (Frame: only the
extensions the runtime lists), system, GL requirements, view configuration, eye size.
2. `Host_Main` (engine init), `VR_Init` (cvars), `TBXR_EnterVR` (session, head-space), `TBXR_InitRenderer`
(reference spaces, swapchains), `TBXR_InitActions` (actions, bindings, attach), `TBXR_WaitForSessionActive`.
3. 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 `shouldRender` is 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`, then `xrEndFrame` with a projection layer (both swapchains) or, when
`VR_UseScreenLayer()`, a 5 x 4.5 m quad layer 4.5 m away that shows eye 0 only.
4. Frame build: each eye is drawn from the located eye pose in head space (`VR_GetEyeTransform`, position scaled by
`vr_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 same `xrLocateViews` at the predicted time. Mono scope eyes and the
screen layer draw from the middle and submit the head pose. (Quest build: fixed 65 mm times `vr_worldscale`, head pose
for both views.)
5. Exit: `runStatus == 0` leaves the loop: `Host_Shutdown`, `TBXR_LeaveVR`, Java `shutdown()` (`System.exit`).
The Frame build also sets it on `EXITING`, `LOSS_PENDING` and 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 quick load (hand behind the head) 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, scoreboard, 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`.
Changed after the second wear (the user's call): the right stick's up and down are unbound in the game (they still scroll in a menu), so turning can't toggle anything. D-pad
left / right are previous / next weapon (aliases of the bumpers), up is the last weapon, down the laser sight (what right stick down did). The D-pad crowbar is gone, the crowbar is
the weapon hand's grip behind the head. The flat screen view has no button any more: it is `vr_flat_screen` (Frame build only), a "Flat Screen View" checkbox on the Video options
page (`mainui/menus/VideoOptions.cpp`, compiled with `-DL1VR_STEAM_FRAME` for the Frame only). `VR_UseScreenLayer()` returns true while it is set, which is the same screen layer the
Quest's toggle shows: the controllers point and click, the scheme code's gameplay half doesn't run, Menu still pauses. Quick load had been on right stick up with the other hand
behind the head; it is View now (right stick click stays quick save), the two buttons a Quest controller has. In multiplayer View toggles the scoreboard only (`+showscores`,
sent from `VrInputFrame.c`), where it used to toggle the flat screen view as well.
The menu tile: `tools/steam_frame/make_frame_tile.py` (Pillow and numpy) reads the release tile `assets/background/800_1_a_loading.tga`, inpaints the Quest help off it and draws the
Frame's, into `Projects/Android/build/frame-assets/background/`. `build.gradle`'s `makeFrameTile` runs it before `mergeFrameAssets` and the `frame` source set adds that folder, so the
Frame APK's tile overrides the release one. A missing tile or module is a warning and the release tile. Not committed, Quest and Pico builds don't see it.
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 load | off hand behind the head |
| View (short) | scoreboard | multiplayer |
| 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 |
| left bumper | previous weapon | |
| right bumper | next weapon | |
| D-pad left | previous weapon | alias of the left bumper |
| D-pad right | next weapon | alias of the right bumper |
| D-pad up | last weapon | |
| D-pad down | laser sight / scope steady | laser sight, steadies a scope |
| 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 left / right; flashlight on the right stick click and View;
quick save on Menu held and (hand behind the head) the right stick click; 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 |
| `diag_hand_yaw`, `diag_hand_pitch` | turn the synthetic hands' aim from the head's (degrees), to point the menu laser. `vr-state.txt` then has `pointer`, `hand_aim_pose`, `beam_origin`, `beam_end`, `layer_order` and each layer's pose; `tools/steam_frame/composite_menu.py DUMP OUT.png` composites them over the menu image |
| `load`, `exit_seconds` | `load` = a save name to `load` instead of a map after the start (`save000`); `exit_seconds` = leave through the normal shutdown N seconds after the first frame. `tools/steam_frame/perf_run.sh` uses both |
| `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) | verified unattended (2026-10-05) as far as standby allows: the dump's `vr-state.txt` has the hand's aim pose, the cursor (u 0.4765, v 0.5211 = pixel 1029, 1126, where the engine's own cursor is drawn), the layer order (screen, beam, dot) and each layer's pose; `tools/steam_frame/composite_menu.py` composites them over the eye 0 menu image from the left eye: the beam leaves the hand and ends in the dot on the cursor, both over the panel. That models the compositor, it isn't SteamVR's output (standby captures are black). Not worn |
| In-game laser sight (`vr_lasersight` 1 and 2) | not seen in four unattended dumps (glock, force_render, hand aimed three ways); the HUD and the weapon draw. Cause not found; needs a look in the headset or a server-side trace |
| Half-Life from Steam in place (`XASH3D_RODIR`) | verified unattended (2026-10-05): with 4174 byte-identical files moved out of `~/Documents/Lambda1VR/valve`, the log says `[data] game files: Steam, in place`, `fs_path` lists the Steam `valve/` as `rodir` and its wads (halflife.wad 3116 lumps), c1a0, the save and c2a5 load, and the hands and replacement weapons still come from Documents (no `Mod_ForName` errors for them). Reads are direct: Documents and Steam are ext4 bind mounts, not FUSE; SELinux is off in Lepton; no avc or EACCES. Case is handled (`exec MEDIA/STARTUPVIDS.TXT` finds `StartupVids.txt`) |
| `vr_refresh_rate`, `vr_resolution_scale` (default 1.25 = 2160) | `vr_resolution_scale` verified (1728, 2160, 2592, 3024 logged and drawn). `vr_refresh_rate` only picks from what the runtime offers (90 Hz alone), so it asked for 90 while SteamVR ran the title at its per-app 120: the rate comes from SteamVR's per-app setting, and only for the Steam title launch (an adb launch runs at 72) |
| 120 Hz and 2160 measurement | done unattended 2026-10-05 from the Steam title (standby, force_render, the user's control-room save): 120 fps, 0 missed at 1728 and 2160, 3 runs each, and in c2a5 at 2160 (3 runs), 2592 and 3024 (1 each); frame period 8.33 ms. Adreno 903 MHz 56-61% at 1728, 100% at 2160 and up. Standby: no display scan-out, so the compositor's cost with the headset worn is not in these numbers |
| Comfort set, haptics table, VR settings page | not started |
| Performance at the Frame's eye size | unattended numbers above; thread CPU 3.1 ms of 8.33, the rest of `cpu_ms` (7.5) is waits (acquire 2.25 ms, wait 0.65 ms, GPU-fenced calls). Worn (2026-10-05): the user saw a smooth 120 fps with the FPS counter on at 2160 per eye, "very sharp and very clean"; no worn frame-time capture |
## 7. Reference display settings (the build that was worn, 2026-10-05)
The user wore the Steam title at its defaults with the FPS counter on: smooth 120 fps, "very sharp and very clean", "feels really good".
This is the reference target for the other Frame ports. Everything below is read from the code or the logs; the file and function
names are the anchor, line numbers are for this commit.
| Setting | Value | Where |
| --- | --- | --- |
| Refresh | 120 Hz, from SteamVR's per-app setting (`~/.config/openvr/config/steamvr.vrsettings`: `steam.app.4086534518` `preferredRefreshRate` 120). `XR_FB_display_refresh_rate` offers only that one rate for an app (the logs said 90 on the first run), so the app's request can't raise it. Only a Steam title launch gets it; an adb launch ran at 72 | `TBXR_ChooseRefreshRate` (`TBXR_Common.c`:1537) |
| App's own request | `vr_refresh_rate` 90 (archived), the highest offered rate not over it, else the lowest. The run asked for 90 and got 120 from SteamVR | `L1VR_SurfaceView.c`:586, `TBXR_Common.c`:1537 |
| Frame period | 8.33 ms (`predictedDisplayPeriod`); `vr_refresh` (walking scale) is set from it, 120 | `L1VR_SurfaceView.c`:705-714 |
| Eye image | 2160 x 2160 = 1728 (recommended) x `vr_resolution_scale` 1.25 (archived, clamped 0.5 to 2, and to the view and swapchain maximum, 8192), rounded down to even. Log: `[openxr] eye image: recommended 1728x1728 ... scale 1.25, using 2160x2160` | `TBXR_SetEyeScale` (`TBXR_Common.c`:1504), `L1VR_SurfaceView.c`:587 |
| Supersampling | none: `SS_MULTIPLIER` is 1.0 on the Frame (1.3 on Quest), only the `-s` command line option changes it, and `TBXR_SetEyeScale` multiplies by it | `TBXR_Common.c`:60-65, `L1VR_SurfaceView.c`:964 |
| Colour format | `GL_SRGB8_ALPHA8` (0x8c43), the engine draws linear into sRGB images. Offered: 0x8c41, 0x8c43, 0x81a5, 0x81a6, 0x8cac | `ovrRenderer_Create` (`TBXR_Common.c`:820), `TBXR_CheckSwapchainFormats` |
| Samples | 1 on the swapchain (`sampleCount = 1`) and `NUM_MULTI_SAMPLES = 1`: no MSAA and no `EXT_multisampled_render_to_texture`. The Frame path attaches the swapchain texture and a depth renderbuffer with the driver's own GLES 3 calls (`GenRenderbuffers`, `FramebufferTexture2D`). The engine's `gl_msaa` is 0 | `TBXR_Common.c`:58, 577, 625-660; `vid_common.c`:901; `opengl.cfg` |
| Swapchain | one per eye, 3 images, 2D (array size 1, face count 1, 1 mip). Log: `swapchain: format 0x8c43, 2160x2160, 3 images, multiview off (one swapchain per eye), 1 samples` | `ovrFramebuffer_Create`, `TBXR_Common.c`:699 |
| Multiview | not used. `GL_OVR_multiview(2)` is advertised, but `glExtensions.multi_view` is detected and never read, and the engine draws each eye in its own pass | `TBXR_Common.c`:260, 279 |
| Depth | `GL_DEPTH_COMPONENT24` renderbuffer per swapchain image, no stencil (the engine's `gl_stencilbits` 8 has no effect on the eye FBO), `glInvalidateFramebuffer` on it after each eye so the tiler doesn't write it back. No `XrCompositionLayerDepthInfoKHR` is submitted | `TBXR_Common.c`:638, 731 |
| Foveation | none. The enum has Quest's `FOVEATION_*` entries and nothing sets them; no `XR_FB_foveation` use on the Frame; the loader log shows Valve's `fdm_injection` XR layer loaded, and I haven't checked what it does for this app | `TBXR_Common.c`:98, 117 |
| Layers | one projection layer, flags `BLEND_TEXTURE_SOURCE_ALPHA` and `CORRECT_CHROMATIC_ABERRATION`, each view with its own pose and the runtime's asymmetric FOV; quad layers for the menu, laser beam and dot | `TBXR_Common.c`:2757-2758, 2815 (quad layers) |
| EGL | ES 3 context (`EGL_CONTEXT_CLIENT_VERSION` 3), RGBA8888, no depth, no stencil, 0 samples | `ovrEgl_CreateContext`, `TBXR_Common.c`:416 |
| gl4es | built with `-DDEFAULT_ES=2`, linked against `libGLESv3.so`: log says `Using GLES 2.0 backend`, "Targeting OpenGL 2.0", full NPOT, FBO in core, `GL_EXT_texture_filter_anisotropic` (max 16), `GL_OES_depth24`, `GL_EXT_color_buffer_float`, sRGB surface supported; Mesa (Zink on Turnip). So the game's GL is gl4es on a GLES 2 backend, in a GLES 3 context, and only the swapchain attachment calls are GLES 3 | `gl4es/Android.mk`:80-84 |
| Texture filtering | `gl_texturemode GL_LINEAR_MIPMAP_LINEAR` (trilinear), `gl_anisotropy 8` (clamped to the driver's 16), `gl_texture_lodbias 0`, `gl_picmip 0`, `gl_skymip 0`, `gl_round_down 0`, `gl_max_size 512` (legacy, no effect), `gl_compress_textures 0`. Eye framebuffer images are `GL_LINEAR` / clamp to edge | `vid_common.c`:886-901; `TBXR_Common.c`:620-624 |
| Other renderer defaults that were in effect | `r_vbo 0`, `r_bump 0`, `r_detailtextures 0` (the device's saved config; the code default is 1), `r_dynamic 1`, `r_lighting_modulate 0.6`, `r_lighting_ambient 0.3`, `gl_overbright 0`, `gl_swapInterval 0`, `gl_finish 0`, `gl_allow_mirrors` forced 0 | the device's `valve/config.cfg`, `vid_android.c`:135 |
| Gamma | `assets/config.cfg` ships `gamma 2.0`, `brightness 0.8` (code default gamma 1.0). The data copy on the Frame had `gamma 2.5` and `fps_max 144`, saved 2026-10-04 21:26, and was read at start of the worn session | `assets/config.cfg`, `valve/config.cfg` on the device |
| Frame cap | `fps_max 144` in the device's config; the compositor's 120 Hz wait paces the loop (`wait_ms` 0.65, `acquire_ms` 2.25) | `[vr-perf]` |
Measured unattended at this configuration (Steam title launch, standby with `force_render`, `scratchpad/step4/summary.txt`; 3 runs per row,
`[vr-perf]` every 10 s):
| Eye size | fps (min..avg) | missed | thread CPU | wait | acquire | `cpu_ms` | Adreno at 903 MHz |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1728 | 120.0..120.0 | 0 | 3.08-3.12 ms | 0.64-0.68 | 2.22-2.26 | 7.43-7.49 | 56-61% |
| 2160 (reference) | 120.0..120.0 | 0 | 3.07-3.11 ms | 0.65-0.66 | 2.24-2.27 | 7.46-7.48 | 100% |
| 2160, `c2a5` (busier map) | 120.0..120.0 | 0 | 3.14-3.18 ms | 1.60-1.66 | 2.25-2.27 | 6.46-6.53 | 100% |
| 2592, 3024 (`c2a5`, 1 run each) | 120.0..120.0 | 0 | 3.17, 3.19 ms | 1.65, 1.82 | 2.24, 2.07 | 6.49, 6.30 | 100% |
Thread CPU is about 3.1 ms of the 8.33 ms budget at every size, and the Adreno is at 100% from 2160 up, so what limits the frame is the GPU side,
not the game thread. The first 10 s window of a run is short of 120 (s125_r1: 107.7 fps, 9 missed, thread CPU 4.06 ms while the map loads) and isn't in the summary. These numbers are from standby: no
display scan-out, so the compositor's cost with the headset worn isn't in them; the user's report is the worn check.
+329 -229
View File
@@ -16,6 +16,7 @@ import java.util.Locale;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
@@ -49,19 +50,36 @@ import static android.system.Os.setenv;
manufacturer = "meta";
}
try
if (BuildConfig.STEAM_FRAME)
{
//Load manufacturer specific loader
System.loadLibrary("openxr_loader_" + manufacturer);
setenv("OPENXR_HMD", manufacturer, true);
} catch (Exception e)
{}
//The Frame has no loader broker, so the Khronos loader is in the APK
System.loadLibrary("openxr_loader");
try
{
setenv("OPENXR_HMD", "steamframe", true);
} catch (Exception e)
{}
}
else
{
try
{
//Load manufacturer specific loader
System.loadLibrary("openxr_loader_" + manufacturer);
setenv("OPENXR_HMD", manufacturer, true);
} catch (Exception e)
{}
}
System.loadLibrary( "xash" );
}
private static final String TAG = "Lambda1VR";
//Where the game data lives. On the Frame it's in Documents, which is the
//headset's own folder and survives a reset of the Android container.
private static final String DATA_DIR = BuildConfig.STEAM_FRAME ? "/sdcard/Documents/Lambda1VR/" : "/sdcard/xash/";
private static final int REQUEST_MANAGE_ALL_FILES = 2296;
@@ -89,13 +107,32 @@ import static android.system.Os.setenv;
}
/** Initializes the Activity only if the permission has been granted. */
private boolean canUseStorage() {
if (Environment.isExternalStorageManager()) {
return true;
}
if (BuildConfig.STEAM_FRAME) {
//The container on the Frame has no settings page for this, and the folder is
//plain files from the host, so all that matters is if we can write to it
File dir = new File(DATA_DIR);
return (dir.isDirectory() || dir.mkdirs()) && dir.canWrite();
}
return false;
}
private void checkPermissionsAndInitialize() {
if (!Environment.isExternalStorageManager()) {
if (!canUseStorage()) {
//request for the permission
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
Uri uri = Uri.fromParts("package", getPackageName(), null);
intent.setData(uri);
startActivityForResult(intent, REQUEST_MANAGE_ALL_FILES);
try {
startActivityForResult(intent, REQUEST_MANAGE_ALL_FILES);
} catch (ActivityNotFoundException e) {
Log.e(TAG, "No settings page for the all files permission, and " + DATA_DIR + " can't be written");
}
finishAffinity(); // Cleanly exit
@@ -114,21 +151,68 @@ import static android.system.Os.setenv;
System.exit(0);
}
/**
* The Frame's Android container shows the headset's Steam folders at the same paths, read only. If
* Half-Life is installed there, the game's files can be used from where they are instead of a copy.
* Looks in the Steam library folders (the main one, and the others libraryfolders.vdf lists, such as
* one on an SD card) for steamapps/common/Half-Life/valve/liblist.gam. Returns the Half-Life folder or null.
*/
private String findSteamHalfLife()
{
final String steam = "/home/steamos/.local/share/Steam";
java.util.ArrayList<String> libraries = new java.util.ArrayList<String>();
libraries.add(steam);
try
{
BufferedReader br = new BufferedReader(new FileReader(steam + "/steamapps/libraryfolders.vdf"));
String line;
while ((line = br.readLine()) != null)
{
// "path" "/some/library"
line = line.trim();
if (line.startsWith("\"path\""))
{
String[] parts = line.split("\"");
if (parts.length >= 4 && !libraries.contains(parts[3]))
{
libraries.add(parts[3]);
}
}
}
br.close();
} catch (IOException e)
{
Log.i(TAG, "[data] no libraryfolders.vdf: " + e.getMessage());
}
for (String library : libraries)
{
File liblist = new File(library + "/steamapps/common/Half-Life/valve/liblist.gam");
if (liblist.canRead())
{
return library + "/steamapps/common/Half-Life";
}
Log.i(TAG, "[data] no Half-Life in the Steam library " + library);
}
return null;
}
public void create()
{
copy_asset(getFilesDir().getPath(), "extras.pak", false);
copy_asset("/sdcard/xash/", "commandline.txt", false); // Copy in case user has deleted their config
copy_asset(DATA_DIR, "commandline.txt", false); // Copy in case user has deleted their config
//Read these from a file and pass through
commandLineParams = new String("xash3d -dev 3 -log");
//See if user is trying to use command line params
if(new File("/sdcard/xash/commandline.txt").exists()) // should exist!
if(new File(DATA_DIR + "commandline.txt").exists()) // should exist!
{
BufferedReader br;
try {
br = new BufferedReader(new FileReader("/sdcard/xash/commandline.txt"));
br = new BufferedReader(new FileReader(DATA_DIR + "commandline.txt"));
String s;
StringBuilder sb=new StringBuilder(0);
while ((s=br.readLine())!=null)
@@ -159,319 +243,335 @@ import static android.system.Os.setenv;
}
//game configuration
copy_asset("/sdcard/xash/" + game + "/", "config.cfg", false); // Copy in case user has deleted their config
copy_asset(DATA_DIR + game + "/", "config.cfg", false); // Copy in case user has deleted their config
//special commands
copy_asset("/sdcard/xash/" + game + "/", "commands.lst", false); // Copy in case user has deleted their config
copy_asset(DATA_DIR + game + "/", "commands.lst", false); // Copy in case user has deleted their config
//Copy our special stuff
copy_asset("/sdcard/xash/" + game + "/", "sprites/s_stealth.spr", true);
copy_asset("/sdcard/xash/" + game + "/", "sprites/vignette.tga", true);
copy_asset("/sdcard/xash/valve/", "sprites/vignette.tga", true); //seems to need to be here for some people
copy_asset(DATA_DIR + game + "/", "sprites/s_stealth.spr", true);
copy_asset(DATA_DIR + game + "/", "sprites/vignette.tga", true);
copy_asset(DATA_DIR + "valve/", "sprites/vignette.tga", true); //seems to need to be here for some people
//Menu Arrow
copy_asset("/sdcard/xash/" + game + "/", "sprites/pointer.tga", true);
copy_asset("/sdcard/xash/valve/", "sprites/pointer.tga", true);
copy_asset(DATA_DIR + game + "/", "sprites/pointer.tga", true);
copy_asset(DATA_DIR + "valve/", "sprites/pointer.tga", true);
//Menu Background
copy_asset("/sdcard/xash/valve/resource/", "background/800_1_a_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_1_b_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_1_c_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_1_d_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_2_a_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_2_b_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_2_c_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_2_d_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_3_a_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_3_b_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_3_c_loading.tga", true);
copy_asset("/sdcard/xash/valve/resource/", "background/800_3_d_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_1_a_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_1_b_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_1_c_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_1_d_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_2_a_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_2_b_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_2_c_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_2_d_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_3_a_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_3_b_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_3_c_loading.tga", true);
copy_asset(DATA_DIR + "valve/resource/", "background/800_3_d_loading.tga", true);
//Copy modified weapon models - This is the base set
if (!(new File("/sdcard/xash/" + game + "/models/no_copy").exists()) && !(game.equalsIgnoreCase("Hunger")))
if (!(new File(DATA_DIR + game + "/models/no_copy").exists()) && !(game.equalsIgnoreCase("Hunger")))
{
//Colt (A-16)
copy_asset("/sdcard/xash/" + game + "/", "models/v_9mmar.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_9mmar.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_9mmar.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/items/clipinsert1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/items/cliprelease1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/t_m4_boltpull.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/t_m4_deploy.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/t_m4_m203_in.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/t_m4_m203_out.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/t_m4_m203_shell.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_9mmar.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_9mmar.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_9mmar.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/items/clipinsert1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/items/cliprelease1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/t_m4_boltpull.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/t_m4_deploy.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/t_m4_m203_in.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/t_m4_m203_out.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/t_m4_m203_shell.wav", true);
//Colt Pistol M1911
copy_asset("/sdcard/xash/" + game + "/", "models/v_9mmhandgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_9mmhandgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_9mmhandgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/glock_magin.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/glock_magout.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/glock_slideforward.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_9mmhandgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_9mmhandgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_9mmhandgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/glock_magin.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/glock_magout.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/glock_slideforward.wav", true);
//357 Python
copy_asset("/sdcard/xash/" + game + "/", "models/v_357.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_357.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_357.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/357_shot1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/357_shot2.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_357.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_357.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_357.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/357_shot1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/357_shot2.wav", true);
//RGD Grenade
copy_asset("/sdcard/xash/" + game + "/", "models/v_grenade.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_grenade.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_grenade.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/grenade_pinpull.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/grenade_throw.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/grenade_draw.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/explode3.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/explode4.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/explode5.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_grenade.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_grenade.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_grenade.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/grenade_pinpull.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/grenade_throw.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/grenade_draw.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/explode3.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/explode4.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/explode5.wav", true);
//Shotgun Benelli M3
copy_asset("/sdcard/xash/" + game + "/", "models/v_shotgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_shotgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_shotgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/m3_insertshell.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/m3_pump.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_shotgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_shotgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_shotgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/m3_insertshell.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/m3_pump.wav", true);
//Stalker Rocket
copy_asset("/sdcard/xash/" + game + "/", "models/v_rpg.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_rpg.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_rpg.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/rpgrocket.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/v_rpgammo.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/rocket1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/rocketfire1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/rpg7_reload.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_rpg.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_rpg.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_rpg.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/rpgrocket.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_rpgammo.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/rocket1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/rocketfire1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/rpg7_reload.wav", true);
//Satchel
copy_asset("/sdcard/xash/" + game + "/", "models/v_satchel.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/v_satchel_radio.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_satchel.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_satchel.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_satchel_radio.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_satchel.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_satchel_radio.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_satchel.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_satchel.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_satchel_radio.mdl", true);
//Tripmine
copy_asset("/sdcard/xash/" + game + "/", "models/v_tripmine.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_tripmine.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/mine_activate.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/mine_deploy.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/tripmine_ant.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/tripmine_button.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/tripmine_move.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_tripmine.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_tripmine.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/mine_activate.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/mine_deploy.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/tripmine_ant.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/tripmine_button.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/tripmine_move.wav", true);
//BMS Crossbow
copy_asset("/sdcard/xash/" + game + "/", "models/v_crossbow.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_crossbow.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_crossbow.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_crossbow_clip.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/natianul.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/xbow.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/xbow_fire1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/xbow_reload1.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_crossbow.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_crossbow.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_crossbow.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_crossbow_clip.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/natianul.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/xbow.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/xbow_fire1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/xbow_reload1.wav", true);
//Egon (Overhaul)
copy_asset("/sdcard/xash/" + game + "/", "models/v_egon.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_egon.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_egon.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_egon.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_egon.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_egon.mdl", true);
//Gauss (Overhaul)
copy_asset("/sdcard/xash/" + game + "/", "models/v_gauss.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_gauss.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_gauss.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_gaussammo.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_gauss.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_gauss.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_gauss.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_gaussammo.mdl", true);
//HGun (Overhaul)
copy_asset("/sdcard/xash/" + game + "/", "models/v_hgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_hgun.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_hgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_hgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_hgun.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_hgun.mdl", true);
//Squeak (Overhaul)
copy_asset("/sdcard/xash/" + game + "/", "models/v_squeak.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/p_squeak.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_squeak.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_sqknest.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_squeak.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/p_squeak.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_squeak.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_sqknest.mdl", true);
//Crowbar
copy_asset("/sdcard/xash/" + game + "/", "models/v_crowbar.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/w_crowbar.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_draw.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_hit1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_hit2.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_hitbod1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_hitbod2.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_hitbod3.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "sound/weapons/cbar_miss1.wav", true);
copy_asset(DATA_DIR + game + "/", "models/v_crowbar.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/w_crowbar.mdl", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_draw.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_hit1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_hit2.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_hitbod1.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_hitbod2.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_hitbod3.wav", true);
copy_asset(DATA_DIR + game + "/", "sound/weapons/cbar_miss1.wav", true);
copy_asset("/sdcard/xash/" + game + "/", "models/v_torch.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "models/v_hand.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_torch.mdl", true);
copy_asset(DATA_DIR + game + "/", "models/v_hand.mdl", true);
}
//Copy Opposing Force specific models
if (game.equalsIgnoreCase("gearbox") &&
!(new File("/sdcard/xash/" + game + "/models/no_copy").exists()))
!(new File(DATA_DIR + game + "/models/no_copy").exists()))
{
//Scope vignette texture
copy_asset("/sdcard/xash/" + game + "/", "sprites/scope.tga", true);
copy_asset("/sdcard/xash/valve/", "sprites/scope.tga", true);
copy_asset(DATA_DIR + game + "/", "sprites/scope.tga", true);
copy_asset(DATA_DIR + "valve/", "sprites/scope.tga", true);
//Sniper Rifle
copy_asset("/sdcard/xash/", "gearbox/models/v_m40a1.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_m40a1.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/scout_clipin.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/scout_clipout.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/sniper_fire.wav", true);
copy_asset(DATA_DIR, "gearbox/models/v_m40a1.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_m40a1.mdl", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/scout_clipin.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/scout_clipout.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/sniper_fire.wav", true);
//Pipe Wrench
copy_asset("/sdcard/xash/", "gearbox/models/v_pipe_wrench.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_pipe_wrench.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/p_pipe_wrench.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_pipe_wrench.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_pipe_wrench.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/p_pipe_wrench.mdl", true);
//Penguins
new File("/sdcard/xash/gearbox/sound/penguin/").mkdirs(); // Make the penguin directory as it won't exist
copy_asset("/sdcard/xash/", "gearbox/sound/penguin/penguin_die1.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/penguin/penguin_deploy1.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/penguin/penguin_hunt1.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/penguin/penguin_hunt2.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/penguin/penguin_hunt3.wav", true);
new File(DATA_DIR + "gearbox/sound/penguin/").mkdirs(); // Make the penguin directory as it won't exist
copy_asset(DATA_DIR, "gearbox/sound/penguin/penguin_die1.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/penguin/penguin_deploy1.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/penguin/penguin_hunt1.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/penguin/penguin_hunt2.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/penguin/penguin_hunt3.wav", true);
//Knife
copy_asset("/sdcard/xash/", "gearbox/models/v_knife.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_knife.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/p_knife.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_knife.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_knife.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/p_knife.mdl", true);
//Desert Eagle
copy_asset("/sdcard/xash/", "gearbox/models/v_desert_eagle.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_desert_eagle.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_desert_eagle.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_desert_eagle.mdl", true);
//Saw
copy_asset("/sdcard/xash/", "gearbox/models/v_saw.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/p_saw.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_saw.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_saw_clip.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/saw_link.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/saw_shell.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/SAW_bolt.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/saw_fire1.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/saw_fire2.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/saw_fire3.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/saw_reload_new.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/saw_reload2_new.wav", true);
copy_asset(DATA_DIR, "gearbox/models/v_saw.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/p_saw.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_saw.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_saw_clip.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/saw_link.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/saw_shell.mdl", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/SAW_bolt.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/saw_fire1.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/saw_fire2.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/saw_fire3.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/saw_reload_new.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/saw_reload2_new.wav", true);
//Barnacle
copy_asset("/sdcard/xash/", "gearbox/models/v_bgrap.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/p_bgrap.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_bgrap.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/v_bgrap_tonguetip.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/saw_link.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/saw_shell.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_cough.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_fire.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_impact.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_pull.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_release.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/bgrapple_wait.wav", true);
copy_asset(DATA_DIR, "gearbox/models/v_bgrap.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/p_bgrap.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_bgrap.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_bgrap_tonguetip.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/saw_link.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/saw_shell.mdl", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_cough.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_fire.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_impact.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_pull.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_release.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/bgrapple_wait.wav", true);
//Shock
copy_asset("/sdcard/xash/", "gearbox/models/v_shock.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_shock.mdl", true);
//Pingu
copy_asset("/sdcard/xash/", "gearbox/models/v_penguin.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_penguin.mdl", true);
//Spore
copy_asset("/sdcard/xash/", "gearbox/models/v_spore_launcher.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_spore_launcher.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/spore.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/spore_ammo.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/splauncher_bounce.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/splauncher_impact.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/spore_hit1.wav", true);
copy_asset(DATA_DIR, "gearbox/models/v_spore_launcher.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_spore_launcher.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/spore.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/spore_ammo.mdl", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/splauncher_bounce.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/splauncher_impact.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/spore_hit1.wav", true);
//Displacer
copy_asset("/sdcard/xash/", "gearbox/models/v_displacer.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/w_displacer.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/models/p_displacer.mdl", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_fire.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_impact.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_self.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_spin.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_spin2.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_start.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_teleport.wav", true);
copy_asset("/sdcard/xash/", "gearbox/sound/weapons/displacer_teleport_player.wav", true);
copy_asset(DATA_DIR, "gearbox/models/v_displacer.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/w_displacer.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/p_displacer.mdl", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_fire.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_impact.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_self.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_spin.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_spin2.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_start.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_teleport.wav", true);
copy_asset(DATA_DIR, "gearbox/sound/weapons/displacer_teleport_player.wav", true);
//Hand
copy_asset("/sdcard/xash/", "gearbox/models/v_hand.mdl", true);
copy_asset(DATA_DIR, "gearbox/models/v_hand.mdl", true);
}
//Copy Blue Shift specific models
if (game.equalsIgnoreCase("bshift") &&
!(new File("/sdcard/xash/" + game + "/models/no_copy").exists()))
!(new File(DATA_DIR + game + "/models/no_copy").exists()))
{
copy_asset("/sdcard/xash/", "bshift/models/v_hand.mdl", true);
copy_asset(DATA_DIR, "bshift/models/v_hand.mdl", true);
}
//Copy They Hunger specific models
if (game.equalsIgnoreCase("Hunger") &&
!(new File("/sdcard/xash/" + game + "/models/no_copy").exists()))
!(new File(DATA_DIR + game + "/models/no_copy").exists()))
{
//HL1 models
copy_asset("/sdcard/xash/" + game + "/", "/models/v_rpg.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_357.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_satchel.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_satchel_radio.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_tripmine.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_crossbow.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_gauss.mdl", true);
copy_asset("/sdcard/xash/" + game + "/", "/models/v_squeak.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_rpg.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_357.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_satchel.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_satchel_radio.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_tripmine.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_crossbow.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_gauss.mdl", true);
copy_asset(DATA_DIR + game + "/", "/models/v_squeak.mdl", true);
//They Hunger models
copy_asset("/sdcard/xash/", "Hunger/models/v_9mmar.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_9mmhandgun.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_ap9.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_crowbar.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_egon.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_hkg36.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_shotgun.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_shovel.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_taurus.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_tfac.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_tfc_medkit.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_tfc_sniper.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_tfc_spanner.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_tnt.mdl", true);
copy_asset("/sdcard/xash/", "Hunger/models/v_hand.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_9mmar.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_9mmhandgun.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_ap9.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_crowbar.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_egon.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_hkg36.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_shotgun.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_shovel.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_taurus.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_tfac.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_tfc_medkit.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_tfc_sniper.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_tfc_spanner.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_tnt.mdl", true);
copy_asset(DATA_DIR, "Hunger/models/v_hand.mdl", true);
}
//Copy Afraid of Monsters Director's Cut specific models
if (game.equalsIgnoreCase("AoMDC") &&
!(new File("/sdcard/xash/" + game + "/models/no_copy").exists()))
!(new File(DATA_DIR + game + "/models/no_copy").exists()))
{
//Afraid of Monsters Director's Cut models
copy_asset("/sdcard/xash/", "AoMDC/models/v_axe.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_beretta.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_deagle.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_glock.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_hammer.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_hand.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_kitchenknife.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_mp5k.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_p228.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_revolver.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_shotgun.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_spear.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_torch.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/v_uzi.mdl", true);
copy_asset("/sdcard/xash/", "AoMDC/models/gmgeneral_display.aomdc", true);
copy_asset(DATA_DIR, "AoMDC/models/v_axe.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_beretta.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_deagle.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_glock.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_hammer.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_hand.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_kitchenknife.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_mp5k.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_p228.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_revolver.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_shotgun.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_spear.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_torch.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/v_uzi.mdl", true);
copy_asset(DATA_DIR, "AoMDC/models/gmgeneral_display.aomdc", true);
}
//Set default environment
try {
ApplicationInfo info = getApplicationInfo();
setenv("XASH3D_BASEDIR", "/sdcard/xash/", true);
setenv("XASH3D_BASEDIR", DATA_DIR, true);
if (BuildConfig.STEAM_FRAME)
{
// Half-Life from Steam, read only, in place. The folder above is searched after it, so what's
// in there (the saves, the settings, this app's own models and sprites) wins over Steam's files.
String steamHalfLife = findSteamHalfLife();
if (steamHalfLife != null)
{
setenv("XASH3D_RODIR", steamHalfLife, true);
Log.i(TAG, "[data] game files: Steam, in place, read only (" + steamHalfLife + "); saves and settings in " + DATA_DIR);
}
else
{
Log.i(TAG, "[data] game files: no Steam install of Half-Life found, using the copy in " + DATA_DIR);
}
}
setenv("XASH3D_GAMELIBDIR", info.nativeLibraryDir, true);
setenv("XASH3D_GAMEDIR", "valve", true);
setenv( "XASH3D_EXTRAS_PAK1", getFilesDir().getPath() + "/extras.pak", true );
-9
View File
@@ -1,9 +0,0 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Dec 12 20:21:11 GMT 2019
ndk.dir=C\:\\Users\\Simon\\AppData\\Local\\Android\\Sdk\\ndk\\21.1.6352462
sdk.dir=C\:\\Users\\Simon\\AppData\\Local\\Android\\Sdk
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Does the HUD stay level when the head rolls? Takes dump directories made with different diag_roll values,
each as a pair: the picture with the HUD (ON) and the same with `hud_draw 0` (OFF), so the HUD is exactly what
differs between them (hands, torch and lamps are in both). It reports, for each roll against the first pair
(the reference, normally roll 0), how far the world turned and how far the HUD turned, both about the eye's
optical axis. In the eye image the head's roll turns the world, and a stabilised HUD has to turn with it
(a HUD that is head-locked does not turn at all).
analyze_roll.py REF_ON REF_OFF ROLL_ON ROLL_OFF [ROLL_ON ROLL_OFF ...]
Needs numpy and pillow (a venv: python3 -m venv /tmp/v && /tmp/v/bin/pip install numpy pillow).
"""
import re
import struct
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def load(d, eye=0):
b = Path(d, f"vr-eye{eye}.bmp").read_bytes()
w, h = struct.unpack("<ii", b[18:26])
off = struct.unpack("<I", b[10:14])[0]
stride = (w * 3 + 3) & ~3
a = np.frombuffer(b, dtype=np.uint8, count=stride * h, offset=off).reshape(h, stride)[:, : w * 3].reshape(h, w, 3)
return Image.fromarray(a[::-1, :, ::-1].copy())
def state(d):
t = Path(d, "vr-state.txt").read_text()
m = re.search(r"fov_eye0 L (\S+) R (\S+) U (\S+) D (\S+)", t)
l, r, u, dn = (float(x) for x in m.groups())
import math
size = int(re.search(r"eye_size (\d+)x", t).group(1))
ax = -math.tan(l) / (math.tan(r) - math.tan(l)) * size
ay = math.tan(u) / (math.tan(u) - math.tan(dn)) * size
roll = float(re.search(r"diag_roll (\S+)", t).group(1))
view = re.search(r"view_roll (\S+)", t)
return ax, ay, roll, float(view.group(1)) if view else None, size
def small(img, f):
return img.resize((img.width // f, img.height // f), Image.BILINEAR)
def best_angle(ref, other, centre, mask, span=40):
"""the rotation of ref (degrees, counter-clockwise on screen) that best matches other where mask is true"""
ref_a = np.asarray(ref, dtype=np.float32)
oth = np.asarray(other, dtype=np.float32)
best = None
for tenth in range(-span * 10, span * 10 + 1, 5):
a = tenth / 10.0
rot = np.asarray(ref.rotate(a, resample=Image.BILINEAR, center=centre), dtype=np.float32)
e = float(np.mean(np.abs(rot - oth)[mask]))
if best is None or e < best[0]:
best = (e, a)
return best[1], best[0]
def hud_mask(on, off, h, w):
d = np.abs(np.asarray(on, dtype=np.float32) - np.asarray(off, dtype=np.float32)).sum(axis=2)
return Image.fromarray(((d > 150) * 255).astype(np.uint8))
def main():
dirs = sys.argv[1:]
if len(dirs) < 4 or len(dirs) % 2:
print(__doc__)
return
f = 4
ax, ay, roll0, view0, size = state(dirs[0])
ref = small(load(dirs[0]), f)
h, w = np.asarray(ref).shape[:2]
ref_mask = hud_mask(ref, small(load(dirs[1]), f), h, w)
print(f"reference {dirs[0]}: diag_roll {roll0}, view roll {view0}, {int(np.asarray(ref_mask).astype(bool).sum())} HUD px")
centre = (ax / f, ay / f)
for i in range(2, len(dirs), 2):
d_on, d_off = dirs[i], dirs[i + 1]
_, _, roll, view, _ = state(d_on)
img = small(load(d_on), f)
mask = hud_mask(img, small(load(d_off), f), h, w)
# the world: the upper middle, away from the HUD, hands and weapon
world = np.zeros((h, w), dtype=bool)
world[int(h * 0.12): int(h * 0.50), int(w * 0.22): int(w * 0.78)] = True
wa, we = best_angle(ref, img, centre, world)
best = None
mimg = np.asarray(mask, dtype=np.float32) / 255.0
for tenth in range(-400, 401, 5):
a = tenth / 10.0
rot = np.asarray(ref_mask.rotate(a, resample=Image.BILINEAR, center=centre), dtype=np.float32) / 255.0
score = float((rot * mimg).sum())
if best is None or score > best[0]:
best = (score, a)
print(f"{d_on}: diag_roll {roll}, view roll {view}: the world turned {wa:+.1f} deg (match error {we:.1f}), "
f"the HUD turned {best[1]:+.1f} deg ({int(mimg.sum())} HUD px)")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Stereo numbers from a dump directory (vr-eye0.bmp, vr-eye1.bmp, vr-state.txt written by dump_frame).
Block-matches the right eye against the left along rows and reports the disparity between the optical
axes, the way a viewer's eyes would see it (positive: nearer than infinity). The asymmetric-frustum
offset between the two eye images is taken out first, from the fov in vr-state.txt.
analyze_stereo.py DIR floor model and a few regions
analyze_stereo.py DIR --box x0 y0 x1 y1 disparity of one box (pixels of the left eye image)
Needs numpy and pillow (a venv: python3 -m venv /tmp/v && /tmp/v/bin/pip install numpy pillow).
The floor model: for a flat floor h below the eyes, the disparity at a row y is ipd * (y - yaxis) / h, so a fit
over the floor rows gives h (it should be the eye height) whatever the texture is.
"""
import math
import re
import struct
import sys
from pathlib import Path
import numpy as np
def load_bmp(path):
d = Path(path).read_bytes()
w, h = struct.unpack("<ii", d[18:26])
off = struct.unpack("<I", d[10:14])[0]
stride = (w * 3 + 3) & ~3
a = np.frombuffer(d, dtype=np.uint8, count=stride * h, offset=off).reshape(h, stride)[:, : w * 3].reshape(h, w, 3)
return a[::-1, :, ::-1].astype(np.float32) # top row first, RGB
def luma(a):
return a @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
def parse_state(path):
t = Path(path).read_text()
st = {}
for eye in (0, 1):
m = re.search(rf"fov_eye{eye} L (\S+) R (\S+) U (\S+) D (\S+)", t)
st[f"fov{eye}"] = tuple(float(x) for x in m.groups())
m = re.search(rf"eye{eye}_in_head_mm (\S+) (\S+) (\S+)", t)
st[f"pos{eye}"] = tuple(float(x) / 1000.0 for x in m.groups()) if m else None
m = re.search(r"eye_size (\d+)x(\d+)", t)
st["size"] = (int(m.group(1)), int(m.group(2)))
return st
def axis_px(fov, size):
l, r, u, d = fov
w, h = size
return (-math.tan(l) / (math.tan(r) - math.tan(l)) * w, math.tan(u) / (math.tan(u) - math.tan(d)) * h)
def focal(fov, size):
l, r, u, d = fov
return size[0] / (math.tan(r) - math.tan(l)), size[1] / (math.tan(u) - math.tan(d))
def match_row_blocks(g0, g1, y0, y1, x0, x1, centre=0, search=110, block=33, predict=None):
"""For each block in the left image find the shift s (x1 = x0 + s) that best matches the right image."""
res = []
half = block // 2
for y in range(y0 + half, y1 - half, block):
for x in range(x0 + half, x1 - half, block):
patch = g0[y - half : y + half + 1, x - half : x + half + 1]
if patch.std() < 6.0:
continue
best, bs = None, 0
c = centre if predict is None else int(round(centre - predict(y)))
for s in range(c - search, c + search + 1):
xs = x + s
if xs - half < 0 or xs + half + 1 > g1.shape[1]:
continue
cand = g1[y - half : y + half + 1, xs - half : xs + half + 1]
e = float(np.mean((patch - cand) ** 2))
if best is None or e < best:
best, bs = e, s
res.append((x, y, bs, best))
return res
def main():
args = sys.argv[1:]
d = Path(args[0])
st = parse_state(d / "vr-state.txt")
g0 = luma(load_bmp(d / "vr-eye0.bmp"))
g1 = luma(load_bmp(d / "vr-eye1.bmp"))
size = st["size"]
ax0, ay0 = axis_px(st["fov0"], size)
ax1, ay1 = axis_px(st["fov1"], size)
f0x, f0y = focal(st["fov0"], size)
f1x, f1y = focal(st["fov1"], size)
p0, p1 = st["pos0"], st["pos1"]
ipd = (p1[0] - p0[0]) if p0 and p1 else 0.063
print(f"axis pixel: eye0 ({ax0:.1f}, {ay0:.1f}) eye1 ({ax1:.1f}, {ay1:.1f}); focal x {f0x:.1f} / {f1x:.1f} px, y {f0y:.1f} / {f1y:.1f} px")
print(f"eye separation {ipd * 1000:.1f} mm; disparity at 1 m would be {ipd * f0x:.1f} px, at 5 m {ipd * f0x / 5:.1f} px")
centre = int(round(ax1 - ax0)) # where the infinity shift lies
def to_disp(x0, s):
# a point at x0 in the left image sits at x0 + s in the right: from the axes, d = (x0 - ax0) - (x0 + s - ax1)
return (x0 - ax0) - (x0 + s - ax1)
probes = [(float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4)))
for m in re.finditer(r"probe (\d+) (\d+) depth_m (\S+) disparity_px (\S+)", (d / "vr-state.txt").read_text())]
if probes:
# ground truth: the engine traced a ray through each of these pixels of the left eye
print(f"{len(probes)} traced probes. u v depth_m predicted measured error (px)")
rows = []
for u, v, depth, pred in probes:
half = 12 if depth < 4.0 else 20
xu, yv = int(u), int(v)
if yv - half < 0 or yv + half + 1 > g0.shape[0] or xu - half < 0 or xu + half + 1 > g0.shape[1]:
continue
patch = g0[yv - half : yv + half + 1, xu - half : xu + half + 1]
if patch.std() < 7.0:
continue
c = int(round(centre - pred))
errs = {}
for sh in range(c - 30, c + 31):
xs = xu + sh
if xs - half < 0 or xs + half + 1 > g1.shape[1]:
continue
errs[sh] = float(np.mean((patch - g1[yv - half : yv + half + 1, xs - half : xs + half + 1]) ** 2))
if len(errs) < 40:
continue
bs = min(errs, key=errs.get)
best = errs[bs]
# a match counts only when it's clearly better than any other place (the floor texture repeats)
others = [e for sh, e in errs.items() if abs(sh - bs) >= 4]
if not others or best > 0.75 * min(others):
continue
# sub-pixel: a parabola through the match and its two neighbours
frac = 0.0
if bs - 1 in errs and bs + 1 in errs:
a, b, c2 = errs[bs - 1], errs[bs], errs[bs + 1]
den = a - 2 * b + c2
if den > 0:
frac = 0.5 * (a - c2) / den
meas = to_disp(xu, bs + frac)
rows.append((depth, pred, meas, u, v))
rows.sort()
for depth, pred, meas, u, v in rows:
print(f" {u:5.0f} {v:5.0f} {depth:7.2f} m {pred:6.2f} {meas:6.1f} {meas - pred:+5.1f}")
for name, lo, hi in (("far (> 6 m)", 6.0, 1e9), ("mid (2 to 6 m)", 2.0, 6.0), ("near (< 2 m)", 0.0, 2.0)):
sel = [(p_, m_) for dep, p_, m_, _, _ in rows if lo <= dep < hi]
if sel:
err = np.array([m_ - p_ for p_, m_ in sel])
print(f"{name}: {len(sel)} probes, predicted {np.mean([p_ for p_, _ in sel]):.1f} px, measured {np.mean([m_ for _, m_ in sel]):.1f} px, "
f"mean error {err.mean():+.2f} px, median {np.median(err):+.2f}, sd {err.std():.2f}")
return
if "--box" in args:
i = args.index("--box")
bx0, by0, bx1, by1 = (int(v) for v in args[i + 1 : i + 5])
res = match_row_blocks(g0, g1, by0, by1, bx0, bx1, centre, block=17)
ds = [to_disp(x, s) for x, y, s, e in res]
if ds:
print(f"box {bx0},{by0}-{bx1},{by1}: {len(ds)} blocks, disparity median {np.median(ds):.1f} px (min {min(ds):.1f}, max {max(ds):.1f})"
f" -> distance {ipd * f0x / max(np.median(ds), 1e-3):.2f} m")
else:
print("no textured blocks")
return
# floor model over the lower part of the picture
# the floor repeats its texture, so look only near what a floor 1.6 m down would give (and fit h afterwards)
res = match_row_blocks(g0, g1, int(size[1] * 0.62), int(size[1] * 0.97), int(size[0] * 0.30), int(size[0] * 0.70), centre,
search=18, block=65, predict=lambda y: ipd * (y - ay0) / 1.6)
if res:
ys = np.array([y for x, y, s, e in res], dtype=np.float32)
ds = np.array([to_disp(x, s) for x, y, s, e in res], dtype=np.float32)
slope, icpt = np.polyfit(ys - ay0, ds, 1)
resid = ds - (slope * (ys - ay0) + icpt)
print(f"floor: {len(res)} blocks, fit d = {slope:.4f} * (y - yaxis) + {icpt:.2f}; residual sd {resid.std():.2f} px")
print(f" eye height above the floor from the slope: {ipd / slope:.3f} m (expected ~1.6 m); offset at the axis row {icpt:.2f} px (expected 0)")
for lo, hi in ((0.62, 0.72), (0.72, 0.85), (0.85, 0.97)):
sel = [(y, dd) for y, dd in zip(ys, ds) if lo * size[1] <= y < hi * size[1]]
if sel:
m = float(np.median([dd for _, dd in sel]))
print(f" rows {lo:.2f}-{hi:.2f} of the height: disparity {m:.1f} px, i.e. {ipd * f0x / max(m, 1e-3):.2f} m away")
# the far region around the centre, above the floor
res = match_row_blocks(g0, g1, int(size[1] * 0.28), int(size[1] * 0.52), int(size[0] * 0.40), int(size[0] * 0.60), centre, block=17)
ds = [to_disp(x, s) for x, y, s, e in res]
if ds:
print(f"centre (far end): {len(ds)} blocks, disparity median {np.median(ds):.1f} px -> {ipd * f0x / max(np.median(ds), 1e-3):.1f} m")
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""What the compositor shows for the main menu: the screen quad (the eye 0 dump as its picture), then the
menu laser's beam and dot quads over it, in the order the app submits them (layer_order in vr-state.txt),
seen from the left eye. The runtime composites for real; this models it from the poses the app logged, so
it checks the maths and the order, not SteamVR. Dev tool.
composite_menu.py DUMP_DIR OUT.png
Needs numpy and pillow (a venv: python3 -m venv /tmp/v && /tmp/v/bin/pip install numpy pillow).
"""
import math
import re
import struct
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
BEAM_COLOUR = (0.15, 0.75, 1.0) # what TBXR_PaintPointerTexture clears the pointer swapchain to (sRGB)
def load_bmp(d):
b = Path(d, "vr-eye0.bmp").read_bytes()
w, h = struct.unpack("<ii", b[18:26])
off = struct.unpack("<I", b[10:14])[0]
stride = (w * 3 + 3) & ~3
a = np.frombuffer(b, dtype=np.uint8, count=stride * h, offset=off).reshape(h, stride)[:, : w * 3].reshape(h, w, 3)
return a[::-1, :, ::-1].copy()
def rotate(q, v):
"""rotate vectors v (..., 3) by quaternion q (x, y, z, w)"""
x, y, z, w = q
u = np.array([x, y, z])
return v + 2.0 * np.cross(u, np.cross(u, v) + w * v)
def conj(q):
return (-q[0], -q[1], -q[2], q[3])
def vec(text, key, n):
m = re.search(key + r" (.*)", text)
return [float(x) for x in re.findall(r"-?\d+\.\d+|-?\d+", m.group(1))][:n]
def layer(text, name):
m = re.search(r"layer_%s_pose (\S+) (\S+) (\S+) quat (\S+) (\S+) (\S+) (\S+) size (\S+) (\S+)" % name, text)
if not m:
return None
v = [float(x) for x in m.groups()]
return np.array(v[0:3]), tuple(v[3:7]), (v[7], v[8])
def hit_quad(origin, dirs, pos, quat, size):
"""ray/quad: returns u (0..1 across, left to right), v (0..1 down), t, inside (all per ray)"""
normal = rotate(quat, np.array([0.0, 0.0, 1.0]))
denom = dirs @ normal
with np.errstate(divide="ignore", invalid="ignore"):
t = ((pos - origin) @ normal) / denom
p = origin + dirs * t[..., None]
local = rotate(conj(quat), p - pos)
u = local[..., 0] / size[0] + 0.5
v = 0.5 - local[..., 1] / size[1]
inside = (t > 0) & (u >= 0) & (u <= 1) & (v >= 0) & (v <= 1)
return u, v, t, inside
def main():
d, out = sys.argv[1], sys.argv[2]
text = Path(d, "vr-state.txt").read_text()
menu = load_bmp(d)
size = menu.shape[0]
head = np.array(vec(text, "head_position", 3))
eye = head + np.array(vec(text, "eye0_in_head_mm", 3)) / 1000.0
l, r, u_, dn = vec(text, "fov_eye0 L", 4)
n = 1080
xs = (np.arange(n) + 0.5) / n
tx = math.tan(l) + xs * (math.tan(r) - math.tan(l))
ty = math.tan(u_) + xs * (math.tan(dn) - math.tan(u_))
dirs = np.stack(np.broadcast_arrays(tx[None, :], ty[:, None], -np.ones((n, n))), axis=-1)
dirs = dirs / np.linalg.norm(dirs, axis=-1, keepdims=True)
img = np.zeros((n, n, 3), np.float32)
order = re.search(r"layer_order (.*)", text).group(1).split()
colour = np.array(BEAM_COLOUR) * 255.0
drawn = {}
for name in order:
lay = layer(text, name)
if lay is None:
drawn[name] = "none"
continue
pos, quat, sz = lay
u, v, t, inside = hit_quad(eye, dirs, pos, quat, sz)
if name == "screen":
px = np.clip((u * (size - 1)).astype(int), 0, size - 1)
py = np.clip((v * (size - 1)).astype(int), 0, size - 1)
img[inside] = menu[py[inside], px[inside]]
else:
img[inside] = colour
drawn[name] = int(inside.sum())
pic = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
pic.save(out)
# a crop around the dot, enlarged, for the eye
dot = layer(text, "dot")
if dot is not None:
rel = dot[0] - eye
fx = rel[0] / -rel[2]
fy = rel[1] / -rel[2]
px = (fx - math.tan(l)) / (math.tan(r) - math.tan(l)) * n
py = (fy - math.tan(u_)) / (math.tan(dn) - math.tan(u_)) * n
c = 90
box = (int(px) - c, int(py) - c, int(px) + c, int(py) + c)
pic.crop(box).resize((c * 6, c * 6), Image.NEAREST).save(out.replace(".png", "-dot.png"))
print("dot at pixel %.0f, %.0f of %d" % (px, py, n))
print("layers in order %s, pixels covered: %s" % (" -> ".join(order), drawn))
main()
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# Install, launch and feed Lambda1VR on a Valve Steam Frame (Android in the Lepton
# container) from a Mac or Linux host. See docs/STEAM_FRAME_VR.md.
#
# tools/steam_frame/deploy.sh [options]
#
# --lock-holder NAME your name in the shared Frame booking (frame-lock.sh). The
# script looks at the current holder and refuses to install,
# launch or register the title unless it is NAME.
# --apk FILE the frame APK (default: the assembleFrame output)
# --data DIR only needed when Half-Life isn't installed in Steam on the Frame
# (the app uses that install in place, read only): rsync a Half-Life
# "valve" folder to the headset's ~/Documents/Lambda1VR/valve. A copy
# there shadows Steam's files. No lock needed.
# --register-title make ~/devkit-game/Lambda1VR a persistent Steam library title
# (once). When the title exists every install refreshes its APK.
# --no-install skip adb install
# --no-launch skip starting the game over adb
# --logcat SECONDS after launching, capture SECONDS of the log into
# scratchpad/frame-logcat.txt and print the key lines
# --diag KEY=VAL set a key in ~/Documents/Lambda1VR/vr-diag.ini (repeatable).
# Keys: force_render, autostart, map, diag_yaw, dump_frame,
# exit_after, perf, oneshot (see docs/VR_ARCHITECTURE.md)
# --diag-oneshot shorthand for --diag oneshot=1: the game renames the file
# when it reads it, so the next start is clean
# --diag-reset delete vr-diag.ini
# --clear-dumps remove ~/Documents/Lambda1VR/vr-dumps before the run
# --pull-dumps DIR adb pull vr-dumps into DIR (PNG copies with sips on a Mac)
# --no-adb only the SSH steps (--data, --diag, --register-title)
#
# Environment:
# FRAME_HOST ssh target (default steamos@192.168.1.31, the Frame on Wi-Fi)
# FRAME_ADB_HOST adb host (default: the host part of FRAME_HOST); Lepton
# Development publishes its adbd on <host>:5555
# ADB adb binary
# FRAME_LOCK path of frame-lock.sh
#
# adb needs "Lepton Development" running (start it from the Frame's Steam library).
# It deletes its apps when it exits, the persistent title and the data in
# ~/Documents/Lambda1VR stay. A binary started by adb gets no controllers and no
# focus from SteamVR: use it for logs, and launch the Steam title to play.
set -euo pipefail
root="$(cd "$(dirname "$0")/../.." && pwd)"
package="com.drbeef.lambda1vr.frame"
activity="com.drbeef.lambda1vr.GLES3JNIActivity"
title="Lambda1VR" # Steam devkit titles must be alphanumeric
title_apk="Lambda1VR.apk" # fixed name: the title's argv stays valid across builds
data_dir_remote="Documents/Lambda1VR"
frame_host="${FRAME_HOST:-steamos@192.168.1.31}"
adb_host="${FRAME_ADB_HOST:-${frame_host#*@}}"
adb_bin="${ADB:-${ANDROID_HOME:-$HOME/Library/Android/sdk}/platform-tools/adb}"
[[ -x "$adb_bin" ]] || adb_bin="$(command -v adb || true)"
frame_lock="${FRAME_LOCK:-$HOME/.claude/skills/steam-frame-vr-port/scripts/frame-lock.sh}"
apk="$root/Projects/Android/build/outputs/apk/frame/lambda1vr-frame.apk"
lock_holder=""
data=""
register=0
install=1
launch=1
use_adb=1
logcat_seconds=0
diag=()
diag_reset=0
clear_dumps=0
pull_dumps=""
die() { echo "deploy.sh: $*" >&2; exit 1; }
while (($#)); do
case "$1" in
--lock-holder) lock_holder="$2"; shift 2 ;;
--apk) apk="$2"; shift 2 ;;
--data) data="$2"; shift 2 ;;
--register-title) register=1; shift ;;
--no-install) install=0; shift ;;
--no-launch) launch=0; shift ;;
--logcat) logcat_seconds="$2"; shift 2 ;;
--diag) diag+=("$2"); shift 2 ;;
--diag-oneshot) diag+=("oneshot=1"); shift ;;
--diag-reset) diag_reset=1; shift ;;
--clear-dumps) clear_dumps=1; shift ;;
--pull-dumps) pull_dumps="$2"; shift 2 ;;
--no-adb) use_adb=0; shift ;;
-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d'; exit 0 ;;
*) die "unknown option $1" ;;
esac
done
ssh_frame() { ssh -o ConnectTimeout=10 -o BatchMode=yes "$frame_host" "$@"; }
# ---- The Frame is shared: only the booking holder may install or launch ------------
needs_lock=0
((use_adb && (install || launch))) && needs_lock=1
((register)) && needs_lock=1
if ((needs_lock)); then
[[ -x "$frame_lock" ]] || die "no frame-lock.sh at $frame_lock (set FRAME_LOCK)"
status="$("$frame_lock" status | head -n 1)"
echo "frame-lock: $status"
if [[ -z "$lock_holder" ]]; then
die "installing, launching and registering need the Frame booking: claim it with frame-lock.sh and pass --lock-holder <name>"
fi
if [[ "$status" =~ ^HELD\ by\ ([^ ]+)\ ]]; then
holder="${BASH_REMATCH[1]}"
[[ "$holder" == "$lock_holder" ]] || die "the Frame is booked by '$holder', not '$lock_holder'"
else
die "nobody holds the Frame booking ($status); claim it first with frame-lock.sh claim $lock_holder"
fi
fi
# ---- Game data: persistent storage on the Frame host ----------------------------------
if [[ -n "$data" ]]; then
[[ -d "$data" ]] || die "no such folder: $data"
[[ -e "$data/liblist.gam" || -e "$data/pak0.pak" || -e "$data/halflife.wad" ]] \
|| echo "warning: $data doesn't look like a Half-Life valve folder (no liblist.gam, pak0.pak or halflife.wad)" >&2
ssh_frame "mkdir -p ~/$data_dir_remote/valve && chmod 777 ~/$data_dir_remote ~/$data_dir_remote/valve"
rsync -a --info=progress2 "${data%/}/" "$frame_host:$data_dir_remote/valve/"
ssh_frame "chmod -R a+rwX ~/$data_dir_remote/valve"
echo "game data in place: ~/$data_dir_remote/valve (/sdcard/$data_dir_remote/valve inside Lepton)"
fi
# ---- Unattended-test switches (files in the data folder) ---------------------------
if ((diag_reset)); then
ssh_frame "rm -f ~/$data_dir_remote/vr-diag.ini ~/$data_dir_remote/vr-diag.ini.last"
echo "vr-diag.ini removed"
fi
if ((${#diag[@]})); then
quoted="$(printf '%q ' "${diag[@]}")"
ssh_frame "mkdir -p ~/$data_dir_remote && python3 - ~/$data_dir_remote/vr-diag.ini $quoted" <<'PY'
import os, sys
path, pairs = sys.argv[1], sys.argv[2:]
lines = open(path).read().splitlines() if os.path.exists(path) else []
for pair in pairs:
key, _, val = pair.partition("=")
for i, line in enumerate(lines):
if line.split("=")[0].strip() == key:
lines[i] = "%s = %s" % (key, val)
break
else:
lines.append("%s = %s" % (key, val))
open(path, "w").write("\n".join(lines) + "\n")
os.chmod(path, 0o666)
print("vr-diag.ini:", " ".join(lines))
PY
fi
if ((clear_dumps)); then
ssh_frame "rm -rf ~/$data_dir_remote/vr-dumps"
fi
[[ -f "$apk" ]] || { ((install || register)) && die "no APK at $apk (build it: cd Projects/Android && sh ./gradlew assembleFrame)"; }
# ---- Persistent Steam title ------------------------------------------------------------
# An existing title always gets the new APK; --register-title creates it.
title_exists=0
ssh_frame "test -d ~/devkit-game/$title" 2>/dev/null && title_exists=1
if ((register || (title_exists && install && use_adb))); then
ssh_frame "mkdir -p ~/devkit-game/$title"
scp -q "$apk" "$frame_host:devkit-game/$title/$title_apk"
echo "copied the APK into ~/devkit-game/$title/$title_apk"
fi
if ((register)); then
parms="{\"gameid\": \"$title\", \"directory\": \"/home/steamos/devkit-game/$title\", \"force_appid\": \"\", \"argv\": [\"$title_apk\"], \"env\": {}, \"settings\": {\"steam_play\": \"1\", \"compat_tool\": \"lepton\"}, \"lepton_args\": \"\"}"
# needs the Steam client running on the Frame; it answers with success or an error
ssh_frame "cd ~/devkit-utils && python3 steam-client-create-shortcut --parms '$parms'"
fi
((use_adb)) || exit 0
[[ -n "$adb_bin" && -x "$adb_bin" ]] || die "adb not found; set ADB"
# ---- Lepton Development over network adb -----------------------------------------------
"$adb_bin" start-server >/dev/null
for attempt in 1 2 3 4 5; do
"$adb_bin" connect "$adb_host:5555" >/dev/null 2>&1 || true
if "$adb_bin" -s "$adb_host:5555" shell true 2>/dev/null; then break; fi
sleep $((attempt * 2))
if ((attempt == 5)); then
die 'cannot reach Lepton Development at '"$adb_host"':5555. Start "Lepton Development" from the Steam library on the Frame.'
fi
done
device="$adb_host:5555"
adb() { "$adb_bin" -s "$device" "$@"; }
if ((install)); then
adb install -r -g "$apk" | grep -i -E 'success|fail|error' || true
# Lepton doesn't enforce all-files access for Documents, but grant it if it's asked for
adb shell appops set "$package" MANAGE_EXTERNAL_STORAGE allow >/dev/null 2>&1 || true
fi
# Don't start anything while someone is wearing the headset (an unattended run over their game). SteamVR
# logs when the headset goes to standby; with nobody in it that happens a few seconds after an app
# closes. If it doesn't within two minutes, someone is in it.
headset_not_worn() {
[[ -n "${FRAME_SKIP_WORN_CHECK:-}" ]] && return 0
for ((i = 0; i < 24; i++)); do
last="$(ssh_frame 'grep -E "entering standby|leaving standby" ~/.local/share/Steam/logs/vrserver.txt | tail -n 1' 2>/dev/null || true)"
[[ -z "$last" || "$last" == *"entering standby"* ]] && return 0
sleep 5
done
return 1
}
if ((launch)); then
headset_not_worn || die "the headset isn't in standby ($last): somebody may be wearing it, not starting the game. Set FRAME_SKIP_WORN_CHECK=1 to override"
# A running game of ours is restarted; nothing else is touched.
adb shell am force-stop "$package"
adb logcat -c
adb shell am start -n "$package/$activity" >/dev/null
echo "started $package; follow it with: $adb_bin -s $device logcat -s TBXR Lambda1VR"
fi
if ((logcat_seconds > 0)); then
out="$root/scratchpad/frame-logcat.txt"
mkdir -p "$(dirname "$out")"
timeout_bin="$(command -v timeout || command -v gtimeout || true)"
if [[ -n "$timeout_bin" ]]; then
"$timeout_bin" "$logcat_seconds" "$adb_bin" -s "$device" logcat -v time >"$out" || true
else
("$adb_bin" -s "$device" logcat -v time >"$out" & pid=$!; sleep "$logcat_seconds"; kill "$pid") 2>/dev/null || true
fi
echo "log: $out"
grep -E "\[openxr\]|\[vr-perf\]|\[vr-dump\]|\[vr-diag\]|Lambda1VR|AndroidRuntime|FATAL" "$out" | head -120 || true
fi
if [[ -n "$pull_dumps" ]]; then
mkdir -p "$pull_dumps"
"$adb_bin" -s "$device" pull "/sdcard/$data_dir_remote/vr-dumps/." "$pull_dumps" >/dev/null
if command -v sips >/dev/null; then
for bmp in "$pull_dumps"/*.bmp; do
[[ -f "$bmp" ]] && sips -s format png "$bmp" --out "${bmp%.bmp}.png" >/dev/null
done
fi
echo "dumps in $pull_dumps: $(ls "$pull_dumps" | tr '\n' ' ')"
fi
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Fill in the assets the repo doesn't track (*.mdl, *.wav, assets/background and
# friends are git-ignored) from Team Beef's own release APK, so a build from source
# gets the same hands, weapons, sounds and loading screens as the Quest release.
#
# tools/steam_frame/fetch_release_assets.sh
#
# Only files missing from assets/ are written; tracked files are never touched.
# Everything it adds stays git-ignored.
set -euo pipefail
tag="v1.7.4"
apk_name="lambda1vr-v1.7.4.apk"
apk_sha256="23e0d2ecb230ca49a4e23bead213735c3ac6097cf46aa741fac76b184adece48"
url="https://github.com/Team-Beef-Studios/Lambda1VR/releases/download/$tag/$apk_name"
root="$(cd "$(dirname "$0")/../.." && pwd)"
cache="$root/Projects/Android/build/release-assets"
apk="$cache/$apk_name"
mkdir -p "$cache"
if [ ! -f "$apk" ] || ! echo "$apk_sha256 $apk" | shasum -a 256 -c - >/dev/null 2>&1; then
echo "downloading $url"
curl -fL --retry 3 -o "$apk.part" "$url"
echo "$apk_sha256 $apk.part" | shasum -a 256 -c - >/dev/null || { echo "SHA-256 mismatch for $apk_name"; rm -f "$apk.part"; exit 1; }
mv "$apk.part" "$apk"
fi
stage="$cache/unpacked"
rm -rf "$stage"
mkdir -p "$stage"
unzip -q "$apk" 'assets/*' -d "$stage"
added=0
while IFS= read -r -d '' f; do
rel="${f#"$stage"/}"
dest="$root/$rel"
[ -e "$dest" ] && continue
if ! git -C "$root" check-ignore -q "$rel"; then
echo "skipping $rel (not git-ignored, so it should come from the repo)"
continue
fi
mkdir -p "$(dirname "$dest")"
cp "$f" "$dest"
added=$((added + 1))
done < <(find "$stage/assets" -type f -print0)
rm -rf "$stage"
echo "added $added files from $apk_name to assets/"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Make the Steam Frame's menu tile: the loading screen tile with the Frame's button mapping on it.
Team Beef's release has the menu background as 12 tiles, assets/background/800_R_C_loading.tga, and
the controller help for the Quest is baked into the first one, 800_1_a_loading.tga. This reads that
tile from the release (the tiles are git-ignored, they aren't in the repo), takes the Quest text off
it and draws the Frame's mapping instead. The result goes into a build folder, never into the repo,
and gradle packs it into the Frame APK only (assembleFrame runs this by itself).
tools/steam_frame/make_frame_tile.py [--src TILE] [--out FILE] [--preview DIR]
Needs Python 3 with Pillow (10.1 or newer) and numpy:
python3 -m venv tools/steam_frame/.venv
tools/steam_frame/.venv/bin/pip install pillow numpy
Exit status: 0 made the tile, 2 the source tile isn't there, 3 Pillow or numpy is missing.
The font is DIN Alternate Bold when the machine has it (macOS), else DejaVu Sans Bold, else the one
that comes with Pillow. Set L1VR_TILE_FONT to a .ttf to pick another.
"""
import argparse
import os
import sys
root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
DEFAULT_SRC = os.path.join(root, 'assets', 'background', '800_1_a_loading.tga')
DEFAULT_OUT = os.path.join(root, 'Projects', 'Android', 'build', 'frame-assets', 'background', '800_1_a_loading.tga')
# One line per thing, "Action = Button", as the Quest's text does. It's the map in VrFrameMap.c
# (docs/STEAM_FRAME_VR.md, Controls). Keep them in step.
LINES = [
'Button Mapping (Steam Frame):',
'Move = Left stick / Turn = Right stick',
'Fire = Right trigger / Alt fire = Left trigger',
'Jump = A / Use = X / Reload = Y',
'Crouch = B (hold) / Click left stick',
'Flashlight = Click right stick / View',
'Weapons = LB / RB or D-pad left / right',
'Last weapon = D-pad up',
'Laser sight = D-pad down',
'Crowbar = Grip behind your head',
'Two handed guns = Left grip',
'Right grip = Use (grab, push, pull)',
'Pause = Menu / Quick save = Hold Menu',
'Recentre = Hold View 1 s (3 s = set height)',
]
INK = (232, 232, 232)
PITCH = 11
X, Y, MAX_WIDTH = 18, 3, 232
FONTS = [
'/System/Library/Fonts/Supplemental/DIN Alternate Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
]
def load_font(ImageFont, size):
wanted = [os.environ['L1VR_TILE_FONT']] if os.environ.get('L1VR_TILE_FONT') else FONTS
for path in wanted:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default(size)
def dilate(mask, steps, np):
# 3x3 box dilation, `steps` times
for _ in range(steps):
p = np.pad(mask, 1)
out = np.zeros_like(mask)
for dy in range(3):
for dx in range(3):
out |= p[dy:dy + mask.shape[0], dx:dx + mask.shape[1]]
mask = out
return mask
def clean_tile(rgb, np):
"""Take the baked-in text off: mask the bright glyphs and what's around them, then fill the mask
from its edges (each masked pixel becomes the average of its neighbours, over and over)."""
i = rgb.astype(np.int32)
gray = np.rint((i[..., 0] * 299 + i[..., 1] * 587 + i[..., 2] * 114) / 1000.0).astype(np.int32)
seed = gray >= 150
seed[170:] = False
mask = dilate((gray > 60) & dilate(seed, 2, np), 1, np)
out = rgb.astype(np.float32)
out[mask] = out[~mask].mean(axis=0)
m = mask[..., None]
for _ in range(400):
p = np.pad(out, ((1, 1), (1, 1), (0, 0)), mode='edge')
avg = (p[:-2, 1:-1] + p[2:, 1:-1] + p[1:-1, :-2] + p[1:-1, 2:]) / 4
out = np.where(m, avg, out)
return np.clip(out + 0.5, 0, 255).astype(np.uint8)
def draw_text(image, ImageDraw, ImageFont):
draw = ImageDraw.Draw(image)
for size in (10, 9, 8, 7):
font = load_font(ImageFont, size)
if all(draw.textlength(t, font=font) <= MAX_WIDTH for t in LINES):
break
for n, text in enumerate(LINES):
draw.text((X, Y + n * PITCH), text, font=font, fill=INK)
def write_preview(folder, tile, src_dir, Image):
"""The whole 800x600 menu background with the new tile in it, and a zoom of the text."""
os.makedirs(folder, exist_ok=True)
full = Image.new('RGB', (800, 600))
for r, row in enumerate('123'):
for c, col in enumerate('abcd'):
name = os.path.join(src_dir, '800_%s_%s_loading.tga' % (row, col))
t = tile if (r, c) == (0, 0) else Image.open(name).convert('RGB') if os.path.exists(name) else None
if t is not None:
full.paste(t.convert('RGB'), (c * 256, r * 256))
full.save(os.path.join(folder, 'compact_800x600.png'))
full.crop((0, 0, 300, 180)).resize((600, 360), Image.LANCZOS).save(os.path.join(folder, 'zoom_header.png'))
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--src', default=DEFAULT_SRC, help='the release tile 800_1_a_loading.tga')
ap.add_argument('--out', default=DEFAULT_OUT, help='where the Frame tile is written')
ap.add_argument('--preview', metavar='DIR', help='also write a 800x600 preview and a zoom of the text here')
args = ap.parse_args()
try:
import numpy as np
from PIL import Image, ImageDraw, ImageFont
except ImportError as e:
print('make_frame_tile: %s. Needs Pillow and numpy, see the top of this file.' % e, file=sys.stderr)
return 3
if not os.path.isfile(args.src):
print('make_frame_tile: %s is not there. It comes from Team Beef\'s release APK '
'(assets/background), see docs/STEAM_FRAME_VR.md.' % args.src, file=sys.stderr)
return 2
source = Image.open(args.src)
if source.size != (256, 256):
print('make_frame_tile: %s is %dx%d, expected 256x256' % (args.src, *source.size), file=sys.stderr)
return 2
alpha = source.getchannel('A') if 'A' in source.getbands() else None
tile = Image.fromarray(clean_tile(np.asarray(source.convert('RGB')), np))
draw_text(tile, ImageDraw, ImageFont)
if alpha is not None:
tile.putalpha(alpha)
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
tile.save(args.out, format='TGA')
print('make_frame_tile: wrote %s' % args.out)
if args.preview:
write_preview(args.preview, tile, os.path.dirname(os.path.abspath(args.src)), Image)
return 0
if __name__ == '__main__':
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# One unattended performance run: starts the game with force_render for N seconds with the given
# diag keys, then prints the [vr-perf] lines of the run and the Adreno's time at each frequency over it
# (devfreq trans_stat, so the share at the top frequency says if the GPU was saturated).
#
# tools/steam_frame/perf_run.sh --lock-holder NAME [--seconds 70] [--load SAVE | --map MAP] [--diag K=V ...]
#
# Several runs per setting, and say what the setting was (the first [vr-perf] line has the eye size and the rate):
# a single run can be a compositor state, not the setting (lesson 67).
set -euo pipefail
root="$(cd "$(dirname "$0")/../.." && pwd)"
frame_host="${FRAME_HOST:-steamos@192.168.1.31}"
seconds=70
holder=""
start=()
diag=()
while (($#)); do
case "$1" in
--lock-holder) holder="$2"; shift 2 ;;
--seconds) seconds="$2"; shift 2 ;;
--load) start=(--diag "load=$2"); shift 2 ;;
--map) start=(--diag "map=$2"); shift 2 ;;
--diag) diag+=(--diag "$2"); shift 2 ;;
*) echo "unknown option $1" >&2; exit 1 ;;
esac
done
[[ -n "$holder" ]] || { echo "need --lock-holder" >&2; exit 1; }
gpu='/sys/class/devfreq/3d00000.gpu/trans_stat'
snap() { ssh -o BatchMode=yes "$frame_host" "cat $gpu" 2>/dev/null || true; }
"$root/tools/steam_frame/deploy.sh" --lock-holder "$holder" --no-install --diag-reset --clear-dumps \
--diag force_render=1 --diag perf=1 "${start[@]}" "${diag[@]}" --diag "exit_seconds=$((seconds))" --no-launch >/dev/null 2>&1
before="$(snap)"
"$root/tools/steam_frame/deploy.sh" --lock-holder "$holder" --no-install --logcat $((seconds + 12)) 2>&1 | grep -E "vr-perf|\[openxr\] (refresh|eye image|frame period)|FORTIFY" || true
after="$(snap)"
python3 - "$before" "$after" <<'PY'
import sys
def rows(t):
r = {}
for line in t.splitlines():
line = line.replace("*", " ")
if ":" in line and line.strip()[0].isdigit():
head, _, rest = line.partition(":")
try:
r[int(head.strip())] = int(rest.split()[-1])
except (ValueError, IndexError):
pass
return r
b, a = rows(sys.argv[1]), rows(sys.argv[2])
if a and b:
d = {k: a[k] - b.get(k, 0) for k in a}
total = sum(d.values()) or 1
top = max(d)
print("gpu devfreq time at each frequency over the run (Hz: ms, share):")
for k in sorted(d):
if d[k]:
print(" %d: %d ms, %.1f%%" % (k, d[k], 100.0 * d[k] / total))
print("gpu at the top frequency %d: %.1f%%" % (top, 100.0 * d[top] / total))
else:
print("no devfreq trans_stat")
PY
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Binding table sanity for the Steam Frame build (Projects/Android/jni/src/Lambda1VR/OpenXrInput.c).
A runtime refuses a whole interaction profile when one suggested path isn't a component of it, and it
refuses to create an action whose name or localized name is already taken in the action set (both have
cost other ports their input). This checks, without a headset:
- every action and localized name given to CreateAction() is unique (names are lower case);
- every path in the Frame and Index tables, and in the Touch block, is a component of that profile
(the component lists are the OpenXR spec's and Valve's; the Frame's is what the other Steam Frame
ports bind: ABXY, Menu and the right bumper on the right controller, View and the D-pad on the left);
- every action in a table was created, and the Frame profile binds what the game needs.
Run: tools/steam_frame/tests/check_bindings.py [OpenXrInput.c] (exit 1 on a problem)
"""
import re
import sys
from pathlib import Path
SRC = Path(__file__).resolve().parents[3] / "Projects/Android/jni/src/Lambda1VR/OpenXrInput.c"
POSES = {"grip/pose", "aim/pose"}
HAPTIC = {"output/haptic"}
STICK = {"thumbstick", "thumbstick/x", "thumbstick/y", "thumbstick/click", "thumbstick/touch"}
PAD = {"trackpad", "trackpad/x", "trackpad/y", "trackpad/touch"}
PROFILES = {
"/interaction_profiles/oculus/touch_controller": (
{"left", "right"}, {"x/click", "x/touch", "y/click", "y/touch", "menu/click", "a/click", "a/touch", "b/click",
"b/touch", "squeeze/value", "trigger/value", "trigger/touch", "thumbrest/touch"}
| STICK | POSES | HAPTIC),
"/interaction_profiles/valve/index_controller": (
{"left", "right"}, {"system/click", "system/touch", "a/click", "a/touch", "b/click", "b/touch",
"squeeze/value", "squeeze/force", "trigger/click", "trigger/value", "trigger/touch",
"trackpad/force"} | STICK | PAD | POSES | HAPTIC),
"/interaction_profiles/valve/frame_controller_valve": (
{"left", "right"}, {"a/click", "b/click", "x/click", "y/click", "menu/click", "view/click", "bumper/click",
"trigger/value", "trigger/click", "squeeze/value", "dpad_up/click", "dpad_down/click",
"dpad_left/click", "dpad_right/click"} | STICK | POSES | HAPTIC),
}
# The Frame is a split gamepad: which hand has which button
FRAME_HAND = {"a/click": "right", "b/click": "right", "x/click": "right", "y/click": "right", "menu/click": "right",
"view/click": "left", "dpad_up/click": "left", "dpad_down/click": "left", "dpad_left/click": "left",
"dpad_right/click": "left"}
# What the game needs from the Frame profile (the layout in TBXR_UpdateFrameLayout)
FRAME_NEEDS = ["touchpadAction", "joystickAction", "thumbstickTouchAction", "triggerAction", "GripAction",
"poseAction", "aimAction", "vibrateAction", "AAction", "BAction", "XAction", "YAction",
"backAction", "viewAction", "bumperAction", "dpadUpAction", "dpadDownAction", "dpadLeftAction",
"dpadRightAction"]
def main():
text = (Path(sys.argv[1]) if len(sys.argv) > 1 else SRC).read_text()
bad = 0
def problem(msg):
nonlocal bad
print("BAD " + msg)
bad += 1
# ---- actions: unique names, unique localized names
created = {}
names, localized = {}, {}
for m in re.finditer(r'CreateAction\(actionSet,\s*\w+,\s*"([^"]+)",\s*"([^"]+)",\s*SIDE_COUNT,\s*handSubactionPath,\s*&(\w+)\)', text):
name, loc, var = m.groups()
created[var] = name
if not re.fullmatch(r"[a-z0-9_.-]+", name):
problem(f"action name '{name}' has characters other than a-z 0-9 _ . -")
if name.lower() in names:
problem(f"action name '{name}' is used twice (also by {names[name.lower()]})")
if loc.lower() in localized:
problem(f"localized name '{loc}' is used twice (also by {localized[loc.lower()]}): XR_ERROR_LOCALIZED_NAME_DUPLICATED")
names[name.lower()] = var
localized[loc.lower()] = var
if not created:
problem("no CreateAction calls found")
# ---- the Frame and Index tables
suggested = {}
for m in re.finditer(r'TBXR_SuggestBindings\((\w+|"[^"]+"),\s*(\w+),', text):
suggested[m.group(2)] = m.group(1)
defines = dict(re.findall(r'#define (\w+_PROFILE) "([^"]+)"', text))
tables = {}
for m in re.finditer(r"static const profileBinding_t (\w+)\[\] = \{(.*?)\n\};", text, re.S):
tables[m.group(1)] = m.group(2)
checked = 0
for table, profile_ref in suggested.items():
profile = defines.get(profile_ref, profile_ref.strip('"'))
if table not in tables:
problem(f"table {table} not found")
continue
hands, comps = PROFILES.get(profile, (None, None))
if comps is None:
problem(f"{profile}: no component list in this test")
continue
checked += 1
actions = set()
for line in tables[table].splitlines():
line = line.split("//")[0]
e = re.search(r'\{\s*&(\w+),\s*"(/user/hand/(\w+)/(input|output)/([\w/]+))"\s*\}', line)
if not e:
continue
action, path, hand, kind, comp = e.groups()
comp = comp if kind == "input" else "output/" + comp
actions.add(action)
if action not in created:
problem(f"{profile}: {action} is never created")
if hand not in hands or comp not in comps:
problem(f"{profile}: {path} is not a component of that profile")
if profile.endswith("frame_controller_valve") and comp in FRAME_HAND and FRAME_HAND[comp] != hand:
problem(f"{profile}: {path}: the Frame has {comp} on the {FRAME_HAND[comp]} controller")
if profile.endswith("frame_controller_valve"):
for need in FRAME_NEEDS:
if need not in actions:
problem(f"the Frame profile doesn't bind {need}")
if not any(p.endswith("frame_controller_valve") for p in (defines.get(r, r.strip('"')) for r in suggested.values())):
problem("no Steam Frame profile suggested")
# ---- the Touch block (paths come from the xrStringToPath lines)
var_path = {}
for m in re.finditer(r'xrStringToPath\(gAppState\.Instance,\s*"(/user/hand/(?:left|right)/[\w/]+)",\s*&(\w+)\[SIDE_(LEFT|RIGHT)\]', text):
var_path[(m.group(2), m.group(3))] = m.group(1)
touch = re.search(r'touchControllerInteractionProfilePath\)\);(.*?)xrSuggestInteractionProfileBindings', text, re.S)
if touch:
hands, comps = PROFILES["/interaction_profiles/oculus/touch_controller"]
checked += 1
for m in re.finditer(r"ActionSuggestedBinding\((\w+),\s*(\w+)\[SIDE_(LEFT|RIGHT)\]\)", touch.group(1)):
action, var, side = m.groups()
path = var_path.get((var, side))
if path is None:
problem(f"Touch: no path for {var}[SIDE_{side}]")
continue
hand, kind, comp = re.match(r"/user/hand/(\w+)/(input|output)/([\w/]+)", path).groups()
comp = comp if kind == "input" else "output/" + comp
if comp not in comps:
problem(f"Touch: {path} is not a component of that profile")
if action not in created:
problem(f"Touch: {action} is never created")
else:
problem("Touch block not found")
print(f"checked {checked} profiles and {len(created)} actions: {'FAILED' if bad else 'ok'}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Host tests for the Steam Frame code that needs no headset.
# tools/steam_frame/tests/run_tests.sh
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
root="$(cd "$here/../../.." && pwd)"
vr="$root/Projects/Android/jni/src/Lambda1VR"
menu="$root/Projects/Android/jni/src/Xash3D/xash3d/mainui/menus"
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
# binding tables and action names against the profiles' component lists
python3 "$here/check_bindings.py"
# LAN listen servers must always have room for the host and one remote player.
cxx="${CXX:-clang++}"
"$cxx" -std=c++11 -Wall -Wextra -Werror -I"$menu" "$here/test_host_game_options.cpp" -o "$out/test_host_game_options"
"$out/test_host_game_options"
# vr-diag.ini parser and the BMP writer
cc="${CC:-clang}"
"$cc" -std=c11 -Wall -Wextra -Werror -I"$vr" "$here/test_diag_core.c" "$vr/VrDiagCore.c" "$vr/VrFrameMap.c" -lm -o "$out/test_diag_core"
"$out/test_diag_core"
# eye offsets, cant, the HUD shift against a projected point, the layer pose
"$cc" -std=c11 -Wall -Wextra -Werror -I"$vr" "$here/test_eye_math.c" "$vr/VrEyeMath.c" -lm -o "$out/test_eye_math"
"$out/test_eye_math"
# the menu laser: the ray against the screen quad, the beam and dot poses
"$cc" -std=c11 -Wall -Wextra -Werror -I"$vr" "$here/test_pointer_math.c" "$vr/VrPointerMath.c" -lm -o "$out/test_pointer_math"
"$out/test_pointer_math"
# the controller map: no input doing two things, every action reachable, aliases only where listed
"$cc" -std=c11 -Wall -Wextra -Werror -I"$vr" "$here/test_frame_map.c" "$vr/VrFrameMap.c" -o "$out/test_frame_map"
"$out/test_frame_map"
# deploy.sh: syntax, and it must refuse to install or launch without the booking
bash -n "$root/tools/steam_frame/deploy.sh"
fake="$out/frame-lock.sh"
printf '#!/bin/sh\necho "HELD by someone-else for 5m0s more: testing (since 10:00)"\n' > "$fake"
chmod +x "$fake"
if FRAME_LOCK="$fake" "$root/tools/steam_frame/deploy.sh" --lock-holder me --apk /nonexistent.apk >/dev/null 2>&1; then
echo "deploy.sh went on without holding the Frame booking"; exit 1
fi
if FRAME_LOCK="$fake" "$root/tools/steam_frame/deploy.sh" --apk /nonexistent.apk >/dev/null 2>&1; then
echo "deploy.sh went on without --lock-holder"; exit 1
fi
echo "deploy.sh lock check: ok"
+102
View File
@@ -0,0 +1,102 @@
// Host test for the vr-diag.ini parser and the BMP writer (VrDiagCore.c).
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "VrDiagCore.h"
#include "VrFrameMap.h"
static int failures = 0;
#define CHECK(cond) do { if (!(cond)) { printf("FAIL line %d: %s\n", __LINE__, #cond); failures++; } } while (0)
int main(void)
{
vrDiagConfig_t c;
// nothing in the file: everything off, the perf line on
VrDiagCore_Defaults(&c);
CHECK(VrDiagCore_Parse("", &c) == 0);
CHECK(!c.forceRender && !c.autostart && c.dumpFrame == 0 && c.exitAfter == 0 && c.perf == 1 && !c.oneshot);
// a normal file, with spaces, other case, comments, CRLF
VrDiagCore_Defaults(&c);
const char* text =
"# an unattended run\r\n"
"force_render = 1\r\n"
" DIAG_YAW=90.5 \r\n"
"; another comment\n"
"autostart = on\n"
"dump_frame = 600\n"
"exit_after=1800\n"
"perf = 0\n"
"probe = 1\n"
"cmd = sv_cheats 1; impulse 101\n"
"diag_roll = -20\n"
"diag_gap = 0.8\n"
"diag_press = A@300+30, lg@400+20 bogus@1+1 B@5\n"
"oneshot = true\n";
CHECK(VrDiagCore_Parse(text, &c) == 12);
CHECK(fabsf(c.gap - 0.8f) < 0.001f);
CHECK(c.pressCount == 2);
CHECK(c.presses[0].input == FI_A && c.presses[0].start == 300 && c.presses[0].length == 30);
CHECK(c.presses[1].input == FI_L_GRIP && c.presses[1].start == 400 && c.presses[1].length == 20);
CHECK(fabsf(c.roll + 20.0f) < 0.001f);
CHECK(c.probe == 1);
CHECK(strcmp(c.cmd, "sv_cheats 1; impulse 101") == 0);
CHECK(c.forceRender == 1);
CHECK(fabsf(c.yaw - 90.5f) < 0.001f);
CHECK(c.autostart == 1 && c.map[0] == '\0');
CHECK(c.dumpFrame == 600 && c.exitAfter == 1800);
CHECK(c.perf == 0 && c.oneshot == 1);
// a map starts the game by itself
VrDiagCore_Defaults(&c);
CHECK(VrDiagCore_Parse("map = c1a0\n", &c) == 1);
CHECK(c.autostart == 1 && strcmp(c.map, "c1a0") == 0);
// junk is ignored, a bad value leaves the default, negatives don't turn things on
VrDiagCore_Defaults(&c);
CHECK(VrDiagCore_Parse("bogus = 1\nforce_render = maybe\nno equals sign\ndump_frame = -5\nexit_after = abc\n= 3\n", &c) == 1);
CHECK(c.forceRender == 0 && c.dumpFrame == 0 && c.exitAfter == 0);
// a long map name is cut, not overrun
VrDiagCore_Defaults(&c);
char longName[200];
memset(longName, 'a', sizeof(longName));
strcpy(longName + 150, "\n");
char line[300];
snprintf(line, sizeof(line), "map = %s", longName);
VrDiagCore_Parse(line, &c);
CHECK(strlen(c.map) == sizeof(c.map) - 1);
// the BMP: 2x2, rows bottom to top, RGBA in, BGR out, rows padded to 4 bytes
const unsigned char rgba[2 * 2 * 4] = {
10, 20, 30, 255, 40, 50, 60, 255, // bottom row
70, 80, 90, 255, 100, 110, 120, 255, // top row
};
char path[] = "/tmp/vrdiag-test.bmp";
CHECK(VrDiagCore_WriteBmp(path, rgba, 2, 2));
FILE* f = fopen(path, "rb");
CHECK(f != NULL);
if (f)
{
unsigned char b[70];
size_t n = fread(b, 1, sizeof(b), f);
fclose(f);
CHECK(n == 54 + 16);
CHECK(b[0] == 'B' && b[1] == 'M');
CHECK(b[2] == 70 && b[10] == 54 && b[14] == 40);
CHECK(b[18] == 2 && b[22] == 2 && b[26] == 1 && b[28] == 24);
CHECK(b[54] == 30 && b[55] == 20 && b[56] == 10); // first pixel, bottom left, as BGR
CHECK(b[57] == 60 && b[58] == 50 && b[59] == 40);
CHECK(b[60] == 0 && b[61] == 0); // row padding
CHECK(b[62] == 90 && b[63] == 80 && b[64] == 70); // next row
}
remove(path);
CHECK(!VrDiagCore_WriteBmp("/tmp/no-such-dir/x.bmp", rgba, 2, 2));
CHECK(!VrDiagCore_WriteBmp(path, rgba, 0, 2));
printf(failures ? "diag core: FAILED (%d)\n" : "diag core: ok\n", failures);
return failures ? 1 : 0;
}
+140
View File
@@ -0,0 +1,140 @@
// Host test for the eye helpers (VrEyeMath.c): eye offsets, the cant, the HUD shift and the
// pose the layer is submitted with. The HUD check is the one that matters: the shifted 2D
// content has to land exactly where a point at that distance lands when projected through
// the same asymmetric frustum from the same eye.
#include <math.h>
#include <stdio.h>
#include "VrEyeMath.h"
static int failures = 0;
#define CHECK(cond) do { if (!(cond)) { printf("FAIL line %d: %s\n", __LINE__, #cond); failures++; } } while (0)
#define NEAR(a, b, eps) (fabsf((a) - (b)) <= (eps))
int main(void)
{
// the Frame's recommended image and a typical pair of asymmetric views
const int W = 1728;
const float L0 = -1.028f, R0 = 0.885f; // left eye
const float L1 = -0.877f, R1 = 1.032f; // right eye
const float eyeX0 = -0.0315f, eyeX1 = 0.0315f;
// ---- offsets: an eye to the left of the head is to the engine's left (+y)
{
const float left[3] = {-0.0315f, 0.0f, 0.0f};
const float right[3] = {0.0315f, 0.0f, 0.0f};
float o[3];
VrEye_ToEngineOffset(left, 40.0f, o);
CHECK(NEAR(o[0], 0.0f, 1e-6f) && NEAR(o[1], 1.26f, 1e-4f) && NEAR(o[2], 0.0f, 1e-6f));
VrEye_ToEngineOffset(right, 40.0f, o);
CHECK(NEAR(o[1], -1.26f, 1e-4f));
// forward (-z) is +x, up is +z
const float fwd[3] = {0.0f, 0.01f, -0.02f};
VrEye_ToEngineOffset(fwd, 100.0f, o);
CHECK(NEAR(o[0], 2.0f, 1e-4f) && NEAR(o[2], 1.0f, 1e-4f));
}
// ---- cant: none, then 5 degrees about the up axis (OpenXR y), then the shorter way round
{
float axis[3], deg;
const float none[4] = {0, 0, 0, 1};
CHECK(!VrEye_CantAxisAngle(none, axis, &deg));
const float zero[4] = {0, 0, 0, 0};
CHECK(!VrEye_CantAxisAngle(zero, axis, &deg));
const float half = 2.5f * (float)M_PI / 180.0f;
const float yaw5[4] = {0, sinf(half), 0, cosf(half)};
CHECK(VrEye_CantAxisAngle(yaw5, axis, &deg));
CHECK(NEAR(deg, 5.0f, 1e-3f));
// up is z in the engine's axes
CHECK(NEAR(axis[0], 0.0f, 1e-5f) && NEAR(axis[1], 0.0f, 1e-5f) && NEAR(axis[2], 1.0f, 1e-5f));
const float yaw5neg[4] = {0, -sinf(half), 0, -cosf(half)}; // the same turn, other sign of q
CHECK(VrEye_CantAxisAngle(yaw5neg, axis, &deg));
CHECK(NEAR(deg, 5.0f, 1e-3f) && NEAR(axis[2], 1.0f, 1e-5f));
// about x (right) is the engine's -y (left): pitch up 3 degrees
const float h3 = 1.5f * (float)M_PI / 180.0f;
const float pitch3[4] = {sinf(h3), 0, 0, cosf(h3)};
CHECK(VrEye_CantAxisAngle(pitch3, axis, &deg));
CHECK(NEAR(deg, 3.0f, 1e-3f) && NEAR(axis[1], -1.0f, 1e-5f));
}
// ---- the axis pixel is where straight ahead lands
{
const float axis0 = VrEye_AxisPixel(L0, R0, W);
CHECK(NEAR(VrEye_ProjectX(0.0f, 0.0f, -10.0f, L0, R0, W), axis0, 1e-2f));
// the formula VR_Get2DOffset uses: centre plus dx
const float tanL = tanf(L0), tanR = tanf(R0);
const float dx = -(tanR + tanL) / (2.0f * (tanR - tanL)) * (float)W;
CHECK(NEAR(W / 2.0f + dx, axis0, 1e-2f));
}
// ---- the HUD: shifted 2D content equals a world point at that distance, both eyes
{
const float distances[] = {0.5f, 1.0f, 2.0f, 5.0f};
for (int i = 0; i < 4; i++)
{
const float D = distances[i];
const float s0 = VrEye_HudShiftPixels(eyeX0, L0, R0, W, D);
const float s1 = VrEye_HudShiftPixels(eyeX1, L1, R1, W, D);
// 2D content anchored at the buffer centre: axis offset, then the shift
const float tanL0 = tanf(L0), tanR0 = tanf(R0), tanL1 = tanf(L1), tanR1 = tanf(R1);
const float u0 = W / 2.0f - (tanR0 + tanL0) / (2.0f * (tanR0 - tanL0)) * W + s0;
const float u1 = W / 2.0f - (tanR1 + tanL1) / (2.0f * (tanR1 - tanL1)) * W + s1;
CHECK(NEAR(u0, VrEye_ProjectX(eyeX0, 0.0f, -D, L0, R0, W), 0.05f));
CHECK(NEAR(u1, VrEye_ProjectX(eyeX1, 0.0f, -D, L1, R1, W), 0.05f));
// the left eye's content moves right, the right eye's left
CHECK(s0 > 0.0f && s1 < 0.0f);
}
// and the old fixed shift (width / 36 = 48 px) really was about 0.4 m
float D = 0.0f;
for (float d = 0.1f; d < 3.0f; d += 0.01f)
if (VrEye_HudShiftPixels(eyeX0, L0, R0, W, d) > 48.0f) D = d;
CHECK(D > 0.3f && D < 0.5f);
// no distance, no shift
CHECK(VrEye_HudShiftPixels(eyeX0, L0, R0, W, 0.0f) == 0.0f);
}
// ---- disparity of world points: IPD * f / depth, and nothing at infinity
{
const float f = VrEye_FocalPixels(L0, R0, W);
const float ipd = eyeX1 - eyeX0;
const float depths[] = {0.5f, 1.0f, 3.0f, 20.0f, 1000.0f};
for (int i = 0; i < 5; i++)
{
const float z = -depths[i];
const float uL = VrEye_ProjectX(eyeX0, 0.0f, z, L0, R0, W);
const float uR = VrEye_ProjectX(eyeX1, 0.0f, z, L0, R0, W);
CHECK(NEAR(uL - uR, ipd * f / depths[i], 1e-2f + 1e-5f * ipd * f / depths[i]));
}
CHECK(NEAR(VrEye_ProjectX(eyeX0, 0.0f, -1e6f, L0, R0, W) - VrEye_ProjectX(eyeX1, 0.0f, -1e6f, L0, R0, W), 0.0f, 0.1f));
}
// ---- the pose a layer view is submitted with: head placed in the stage, eye placed in the head
{
const float s = sqrtf(0.5f);
// head 1.6 m up, 2 m to the right of the origin, turned 90 degrees left (about y)
const float head[7] = {2.0f, 1.6f, 0.0f, 0.0f, s, 0.0f, s};
// eye 32 mm to the head's left, no cant
const float eye[7] = {-0.032f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f};
float out[7];
VrEye_ComposePose(head, eye, out);
// facing left, the head's left is the stage's +z... turned 90 deg left, the head's -x
// becomes the stage's +z
CHECK(NEAR(out[0], 2.0f, 1e-5f) && NEAR(out[1], 1.6f, 1e-5f) && NEAR(out[2], 0.032f, 1e-5f));
CHECK(NEAR(out[3], 0.0f, 1e-6f) && NEAR(out[4], s, 1e-5f) && NEAR(out[6], s, 1e-5f));
// with the eye's own cant of 5 degrees about y: the orientations add
const float half = 2.5f * (float)M_PI / 180.0f;
const float eyeCant[7] = {0, 0, 0, 0, sinf(half), 0, cosf(half)};
VrEye_ComposePose(head, eyeCant, out);
const float total = (90.0f + 5.0f) * 0.5f * (float)M_PI / 180.0f;
CHECK(NEAR(out[4], sinf(total), 1e-5f) && NEAR(out[6], cosf(total), 1e-5f));
}
printf(failures ? "eye math: FAILED (%d)\n" : "eye math: ok\n", failures);
return failures ? 1 : 0;
}
+191
View File
@@ -0,0 +1,191 @@
// Host test for the Steam Frame controller map (VrFrameMap.c). It builds the effective table, physical
// input to game action, for both hands and every context (playing, the hands behind the head, a
// menu, multiplayer), and fails on:
// - one physical input doing two different things in the same context;
// - an action with two inputs that isn't a documented alias;
// - an action that nothing can reach;
// - a grip or trigger doing something that isn't a hand's job;
// - the right stick's up or down doing anything in the game (it only turns, and scrolls in a menu);
// - a button for the flat screen view (that's the vr_flat_screen setting) or the D-pad crowbar.
// With --markdown it prints the table for the docs.
#include <stdio.h>
#include <string.h>
#include "VrFrameMap.h"
static int failures = 0;
#define FAIL(...) do { printf("FAIL: " __VA_ARGS__); printf("\n"); failures++; } while (0)
// actions that may have more than one input in a context, because they are meant to
static bool aliasAllowed(int action)
{
switch (action)
{
case ACT_USE: // X and the weapon hand's grip
case ACT_PREV_WEAPON: // left bumper and D-pad left
case ACT_NEXT_WEAPON: // right bumper and D-pad right
case ACT_FLASHLIGHT: // right stick click and View
case ACT_QUICKSAVE: // hold Menu; with the hand behind the head the right stick click
case ACT_MENU_BACK: // B, Menu, View
case ACT_MENU_UP: case ACT_MENU_DOWN: case ACT_MENU_LEFT: case ACT_MENU_RIGHT: // D-pad and left stick
case ACT_MOVE: case ACT_TURN: // four stick directions
return true;
default:
return false;
}
}
static bool isHandInput(int input)
{
return input == FI_L_GRIP || input == FI_R_GRIP;
}
static bool handAction(int action)
{
return action == ACT_BACKPACK || action == ACT_USE || action == ACT_USE_OFF || action == ACT_STEADY || action == ACT_NONE;
}
int main(int argc, char** argv)
{
int rowCount;
const frameMapRow_t* rows = VrFrameMap_Rows(&rowCount);
bool reached[ACT_COUNT] = {false};
for (int hand = 0; hand < 2; hand++)
{
const bool left = hand == 1;
for (int context = 0; context < 32; context++)
{
// a menu has no hands behind the head
if ((context & CTX_MENU) && (context & (CTX_DOM_ZONE | CTX_OFF_ZONE)))
continue;
// quick save and load aren't offered in multiplayer
if ((context & CTX_MULTI) && (context & CTX_OFF_ZONE))
continue;
int inputsOfAction[ACT_COUNT] = {0};
for (int input = 0; input < FI_COUNT; input++)
{
// overlapping rows: how many match this input in this context
int matches = 0, firstAction = ACT_NONE;
for (int r = 0; r < rowCount; r++)
{
const int rowInput = rows[r].role ? VrFrameMap_RoleInput(rows[r].role, left) : rows[r].input;
if (rowInput != input) continue;
if ((context & rows[r].require) != rows[r].require || (context & rows[r].forbid) != 0) continue;
if (matches++ == 0) firstAction = rows[r].action;
else FAIL("%s: %s does %s and %s (context %d, %s)", left ? "left-handed" : "right-handed",
VrFrameMap_InputName(input), VrFrameMap_ActionName(firstAction), VrFrameMap_ActionName(rows[r].action),
context, "rows overlap");
}
const int action = VrFrameMap_Action(input, context, left);
if (action != firstAction) FAIL("Action() and the rows disagree for %s", VrFrameMap_InputName(input));
if (action == ACT_NONE) continue;
inputsOfAction[action]++;
reached[action] = true;
if (isHandInput(input) && !handAction(action))
FAIL("%s does %s, which isn't a hand's job", VrFrameMap_InputName(input), VrFrameMap_ActionName(action));
}
for (int action = 1; action < ACT_COUNT; action++)
if (inputsOfAction[action] > 1 && !aliasAllowed(action))
FAIL("%s: %s has %d inputs in context %d and isn't a listed alias", left ? "left-handed" : "right-handed",
VrFrameMap_ActionName(action), inputsOfAction[action], context);
}
}
// everything must be reachable somewhere
for (int action = 1; action < ACT_COUNT; action++)
if (!reached[action]) FAIL("nothing does %s", VrFrameMap_ActionName(action));
// the hands: face buttons, bumpers, D-pad, Menu, View and sticks don't change with the weapon hand;
// the weapon hand's trigger fires and its grip is the interaction / backpack one
for (int context = 0; context < 32; context++)
{
for (int input = 0; input < FI_COUNT; input++)
{
if (input == FI_L_TRIGGER || input == FI_R_TRIGGER || input == FI_L_GRIP || input == FI_R_GRIP) continue;
if (VrFrameMap_Action(input, context, false) != VrFrameMap_Action(input, context, true))
FAIL("%s changes with the hand in context %d", VrFrameMap_InputName(input), context);
}
if (!(context & CTX_MENU))
{
if (VrFrameMap_Action(FI_R_TRIGGER, context, false) != ACT_FIRE || VrFrameMap_Action(FI_L_TRIGGER, context, false) != ACT_ALTFIRE)
FAIL("right-handed triggers");
if (VrFrameMap_Action(FI_L_TRIGGER, context, true) != ACT_FIRE || VrFrameMap_Action(FI_R_TRIGGER, context, true) != ACT_ALTFIRE)
FAIL("left-handed triggers");
}
else if (VrFrameMap_Action(FI_R_TRIGGER, context, false) != ACT_MENU_CLICK || VrFrameMap_Action(FI_L_TRIGGER, context, true) != ACT_MENU_CLICK)
FAIL("the laser's trigger in a menu");
}
// the things the user asked for by name
struct { int input; int context; int action; } expect[] = {
{ FI_A, 0, ACT_JUMP }, { FI_B, 0, ACT_CROUCH_HOLD }, { FI_X, 0, ACT_USE }, { FI_Y, 0, ACT_RELOAD },
{ FI_L_STICK_CLICK, 0, ACT_CROUCH_TOGGLE }, { FI_R_STICK_CLICK, 0, ACT_FLASHLIGHT },
{ FI_L_BUMPER, 0, ACT_PREV_WEAPON }, { FI_R_BUMPER, 0, ACT_NEXT_WEAPON },
{ FI_DPAD_LEFT, 0, ACT_PREV_WEAPON }, { FI_DPAD_RIGHT, 0, ACT_NEXT_WEAPON },
{ FI_DPAD_UP, 0, ACT_LAST_WEAPON }, { FI_DPAD_DOWN, 0, ACT_LASER },
{ FI_VIEW_SHORT, CTX_OFF_ZONE, ACT_QUICKLOAD }, { FI_R_STICK_CLICK, CTX_OFF_ZONE, ACT_QUICKSAVE },
{ FI_VIEW_SHORT, CTX_MULTI, ACT_SCOREBOARD },
{ FI_MENU_SHORT, 0, ACT_PAUSE }, { FI_MENU_HOLD, 0, ACT_QUICKSAVE },
{ FI_VIEW_SHORT, 0, ACT_FLASHLIGHT }, { FI_VIEW_HOLD_1S, 0, ACT_RECENTRE }, { FI_VIEW_HOLD_3S, 0, ACT_RECENTRE_HEIGHT },
{ FI_R_GRIP, 0, ACT_USE }, { FI_R_GRIP, CTX_DOM_ZONE, ACT_BACKPACK },
{ FI_L_GRIP, 0, ACT_STEADY }, { FI_L_GRIP, CTX_OFF_GRIP_AWAY, ACT_USE_OFF },
{ FI_A, CTX_MENU, ACT_MENU_CONFIRM }, { FI_B, CTX_MENU, ACT_MENU_BACK },
{ FI_DPAD_UP, CTX_MENU, ACT_MENU_UP }, { FI_L_STICK_UP, CTX_MENU, ACT_MENU_UP },
};
for (size_t i = 0; i < sizeof(expect) / sizeof(expect[0]); i++)
if (VrFrameMap_Action(expect[i].input, expect[i].context, false) != expect[i].action)
FAIL("%s should be %s (context %d)", VrFrameMap_InputName(expect[i].input), VrFrameMap_ActionName(expect[i].action), expect[i].context);
// the right stick's up and down are not bound in the game, in any context outside a menu (and the
// flat screen view has no action to reach, so no input can do it)
for (int hand = 0; hand < 2; hand++)
for (int context = 0; context < 32; context++)
{
if (context & CTX_MENU) continue;
if (VrFrameMap_Action(FI_R_STICK_UP, context, hand == 1) != ACT_NONE)
FAIL("right stick up does %s in the game (context %d)", VrFrameMap_ActionName(VrFrameMap_Action(FI_R_STICK_UP, context, hand == 1)), context);
if (VrFrameMap_Action(FI_R_STICK_DOWN, context, hand == 1) != ACT_NONE)
FAIL("right stick down does %s in the game (context %d)", VrFrameMap_ActionName(VrFrameMap_Action(FI_R_STICK_DOWN, context, hand == 1)), context);
}
for (int r = 0; r < rowCount; r++)
{
if (rows[r].require & CTX_MENU) continue;
if (rows[r].input == FI_R_STICK_UP || rows[r].input == FI_R_STICK_DOWN)
FAIL("a game row for the right stick's %s", VrFrameMap_InputName(rows[r].input));
}
for (int action = 1; action < ACT_COUNT; action++)
if (strstr(VrFrameMap_ActionName(action), "screen view") || strcmp(VrFrameMap_ActionName(action), "crowbar") == 0)
FAIL("%s is an action again", VrFrameMap_ActionName(action));
// right stick up and down still scroll in a menu
if (VrFrameMap_Action(FI_R_STICK_UP, CTX_MENU, false) != ACT_MENU_SCROLL_UP || VrFrameMap_Action(FI_R_STICK_DOWN, CTX_MENU, false) != ACT_MENU_SCROLL_DOWN)
FAIL("the right stick should still scroll in a menu");
// the crowbar is the grip behind the head, as it was
if (VrFrameMap_Action(FI_R_GRIP, CTX_DOM_ZONE, false) != ACT_BACKPACK || VrFrameMap_Action(FI_L_GRIP, CTX_DOM_ZONE, true) != ACT_BACKPACK)
FAIL("the grip behind the head should be the backpack weapon");
if (argc > 1 && strcmp(argv[1], "--markdown") == 0)
{
printf("| Frame input | Playing | Notes |\n| --- | --- | --- |\n");
for (int r = 0; r < rowCount; r++)
{
if (rows[r].require & CTX_MENU) continue;
const char* name = rows[r].role == ROLE_TRIGGER_WEAPON ? "weapon hand trigger" : rows[r].role == ROLE_TRIGGER_OTHER ? "other hand trigger"
: rows[r].role == ROLE_GRIP_WEAPON ? "weapon hand grip" : rows[r].role == ROLE_GRIP_OTHER ? "other hand grip" : VrFrameMap_InputName(rows[r].input);
printf("| %s | %s | %s |\n", name, VrFrameMap_ActionName(rows[r].action), rows[r].note);
}
printf("\n| Frame input | In a menu | Notes |\n| --- | --- | --- |\n");
for (int r = 0; r < rowCount; r++)
{
if (!(rows[r].require & CTX_MENU)) continue;
const char* name = rows[r].role == ROLE_TRIGGER_WEAPON ? "weapon hand trigger" : VrFrameMap_InputName(rows[r].input);
printf("| %s | %s | %s |\n", name, VrFrameMap_ActionName(rows[r].action), rows[r].note);
}
}
printf(failures ? "frame map: FAILED (%d)\n" : "frame map: ok\n", failures);
return failures ? 1 : 0;
}
@@ -0,0 +1,15 @@
#include "HostGameOptions.h"
#include <cassert>
int main()
{
assert( HostGame_NormalizeMaxPlayers( -1 ) == 2 );
assert( HostGame_NormalizeMaxPlayers( 0 ) == 2 );
assert( HostGame_NormalizeMaxPlayers( 1 ) == 2 );
assert( HostGame_NormalizeMaxPlayers( 2 ) == 2 );
assert( HostGame_NormalizeMaxPlayers( 12 ) == 12 );
assert( HostGame_NormalizeMaxPlayers( 32 ) == 32 );
assert( HostGame_NormalizeMaxPlayers( 33 ) == 32 );
return 0;
}
+102
View File
@@ -0,0 +1,102 @@
// Host test for the menu laser's maths (VrPointerMath.c)
#include <math.h>
#include <stdio.h>
#include "VrPointerMath.h"
static int failures = 0;
#define CHECK(cond) do { if (!(cond)) { printf("FAIL line %d: %s\n", __LINE__, #cond); failures++; } } while (0)
#define NEAR(a, b, eps) (fabsf((a) - (b)) <= (eps))
static void rotate(const float q[4], const float v[3], float out[3])
{
const float x = q[0], y = q[1], z = q[2], w = q[3];
const float cx = y * v[2] - z * v[1], cy = z * v[0] - x * v[2], cz = x * v[1] - y * v[0];
const float dx = y * cz - z * cy, dy = z * cx - x * cz, dz = x * cy - y * cx;
out[0] = v[0] + 2 * (w * cx + dx); out[1] = v[1] + 2 * (w * cy + dy); out[2] = v[2] + 2 * (w * cz + dz);
}
int main(void)
{
const float W = 5.0f, H = 4.5f;
// a quad 4.5 m ahead, 1 m up, facing the viewer, and a hand a little right of and above the middle
const float quad[7] = {0.0f, 1.0f, -4.5f, 0, 0, 0, 1};
const float hand[3] = {0.3f, 1.2f, -0.3f};
const float ahead[3] = {0, 0, -1};
float u, v, t;
CHECK(VrPointer_RayQuad(hand, ahead, quad, W, H, &u, &v, &t));
CHECK(NEAR(u, 0.5f + 0.3f / W, 1e-4f) && NEAR(v, 0.5f - 0.2f / H, 1e-4f) && NEAR(t, 4.2f, 1e-4f));
// pointing right by 10 degrees moves it right by 4.2 * tan(10)
const float right10[3] = {sinf(10.0f * (float)M_PI / 180.0f), 0, -cosf(10.0f * (float)M_PI / 180.0f)};
CHECK(VrPointer_RayQuad(hand, right10, quad, W, H, &u, &v, &t));
CHECK(NEAR(u, 0.5f + (0.3f + 4.2f * tanf(10.0f * (float)M_PI / 180.0f)) / W, 1e-3f));
// the direction needn't be a unit vector
const float longAhead[3] = {0, 0, -7};
CHECK(VrPointer_RayQuad(hand, longAhead, quad, W, H, &u, &v, &t) && NEAR(t, 4.2f, 1e-4f));
// pointing off the panel still gives u and v, outside 0..1
const float wide[3] = {1.0f, 0, -0.2f};
CHECK(VrPointer_RayQuad(hand, wide, quad, W, H, &u, &v, &t) && u > 1.0f);
// away from it, along it, behind the hand
const float back[3] = {0, 0, 1};
CHECK(!VrPointer_RayQuad(hand, back, quad, W, H, &u, &v, &t));
const float along[3] = {1, 0, 0};
CHECK(!VrPointer_RayQuad(hand, along, quad, W, H, &u, &v, &t));
const float behindHand[3] = {0.3f, 1.2f, -9.0f};
CHECK(!VrPointer_RayQuad(behindHand, ahead, quad, W, H, &u, &v, &t));
// a quad turned 90 degrees left about y (what the screen does after a snap turn): it sits to the left
// of the origin facing +x; a hand at the origin pointing at it
const float s = sqrtf(0.5f);
const float turned[7] = {-4.5f, 1.0f, 0.0f, 0, s, 0, s};
const float toLeft[3] = {-1, 0, 0};
const float origin[3] = {0, 1.0f, 0};
CHECK(VrPointer_RayQuad(origin, toLeft, turned, W, H, &u, &v, &t));
CHECK(NEAR(u, 0.5f, 1e-4f) && NEAR(v, 0.5f, 1e-4f) && NEAR(t, 4.5f, 1e-4f));
// aimed a little toward -z: the quad turned left faces +x, so its right is toward -z
const float slightly[3] = {-1, 0, -0.1f};
CHECK(VrPointer_RayQuad(origin, slightly, turned, W, H, &u, &v, &t));
CHECK(u > 0.5f);
// the aim pose's forward
const float aim[7] = {0, 0, 0, 0, s, 0, s}; // turned 90 left: forward is -x
float f[3];
VrPointer_Forward(aim, f);
CHECK(NEAR(f[0], -1.0f, 1e-5f) && NEAR(f[1], 0.0f, 1e-5f) && NEAR(f[2], 0.0f, 1e-5f));
// the beam: its long axis along the line, its face to the eye, whatever way the line goes
const float starts[4][3] = {{0.3f, 1.2f, -0.3f}, {0, 1, 0}, {1, 2, -1}, {-0.4f, 0.9f, -0.2f}};
const float ends[4][3] = {{0.5f, 1.0f, -4.4f}, {0, 1, -4}, {1, -3, -1.0f}, {-0.4f, 5.0f, -0.2f}};
const float eye[3] = {0.0f, 1.6f, 0.0f};
for (int i = 0; i < 4; i++)
{
float pose[7], axisY[3] = {0, 1, 0}, axisZ[3] = {0, 0, 1}, axisX[3] = {1, 0, 0}, y[3], z[3], x[3];
const float length = VrPointer_BeamPose(starts[i], ends[i], eye, pose);
const float dx = ends[i][0] - starts[i][0], dy = ends[i][1] - starts[i][1], dz = ends[i][2] - starts[i][2];
const float expected = sqrtf(dx * dx + dy * dy + dz * dz);
CHECK(NEAR(length, expected, 1e-4f));
rotate(pose + 3, axisY, y);
rotate(pose + 3, axisZ, z);
rotate(pose + 3, axisX, x);
CHECK(NEAR(y[0], dx / expected, 1e-4f) && NEAR(y[1], dy / expected, 1e-4f) && NEAR(y[2], dz / expected, 1e-4f));
CHECK(NEAR(x[0] * y[0] + x[1] * y[1] + x[2] * y[2], 0.0f, 1e-4f));
const float toEye[3] = {eye[0] - pose[0], eye[1] - pose[1], eye[2] - pose[2]};
CHECK(z[0] * toEye[0] + z[1] * toEye[1] + z[2] * toEye[2] > 0.0f);
CHECK(NEAR(pose[3] * pose[3] + pose[4] * pose[4] + pose[5] * pose[5] + pose[6] * pose[6], 1.0f, 1e-4f));
CHECK(NEAR(pose[0], (starts[i][0] + ends[i][0]) / 2, 1e-5f));
}
// the dot: turned like the panel, a little out of its face
const float point[3] = {0.5f, 1.0f, -4.5f};
float dot[7];
VrPointer_DotPose(point, quad, 0.01f, dot);
CHECK(NEAR(dot[2], -4.49f, 1e-5f) && NEAR(dot[6], 1.0f, 1e-6f));
VrPointer_DotPose((const float[3]){-4.5f, 1.0f, 0.0f}, turned, 0.01f, dot);
CHECK(NEAR(dot[0], -4.49f, 1e-4f)); // the turned quad faces +x, so toward the origin is +x
printf(failures ? "pointer math: FAILED (%d)\n" : "pointer math: ok\n", failures);
return failures ? 1 : 0;
}