[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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.