Compare commits

..
7 Commits
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 8662777f11 Merge feature/frame-updater: the Steam Frame's launcher updates the game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:11 +02:00
Stefan MaierandClaude Opus 5.5 e24df8ead9 The Steam Frame's launcher updates the game
The game's own updater (the Quest's) cannot on the Steam Frame: Lepton
installs the APK in the game's folder there at every start. So the
launcher does it: while the game runs it asks GitHub for the latest
release, fetches a newer one's Steam Frame bundle into update/, checks
it (its build.txt is the release's number), and puts the APK and the
launcher in place as the next start begins, before Lepton runs the APK.
update.auto = false in config.toml stops it, as on the Quest; a build
with no number (build.txt, which the bundle now carries and the
installer copies; 0 for local builds) never looks; without the internet,
or when anything fails, nothing changes. The in-game updater no longer
starts there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:40:26 +02:00
Stefan MaierandClaude Opus 5.5 7a10a413c8 Merge feature/vr-refresh-auto: the eyes drawn one by one (multiview's doubled pictures online), the refresh rate stepped down while frames come late
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:57:18 +02:00
Stefan MaierandClaude Opus 5.5 5ce39c27a7 Roadmap: multiview to look into later; refresh rate steps in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:57:17 +02:00
Stefan MaierandClaude Opus 5.5 67d738451c Draw the eyes one by one by default; multiview only with debug.multiview
On the Quest 3, in big online games (26-35 players), the picture was
doubled and shifted from the start as the head turned, nauseating; with
48 bots it was not, and with the eyes drawn one by one it was not online
either. Not the frame rate: one by one, online at 72 Hz, the compositor
repeated as many frames and nothing was seen. The Steam Frame had the
same doubling with multiview (2026-10-05) and draws them one by one
since. So both now do by default; debug.multiview (was vr.multiview, and
debug.multiview_steamvr) draws them at once, for finding what the shared
pass draws wrongly. The refresh rate's steps take up the extra cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:48:27 +02:00
Stefan MaierandClaude Opus 5.5 91bdb7e67c Refresh rate: step down at one late frame in 32, show the rate it runs at
The first try stepped 90 to 80 Hz a few seconds into a 30-player game,
and no doubled picture was seen, but 2-5 stale frames a second were left
at 80: one late frame in 32 (was 16) takes it on to 72. The VR options
show the rate chosen and the one it runs at while lowered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:44:18 +02:00
Stefan MaierandClaude Opus 5.5 bf778b6d60 Step the refresh rate down while frames come late (vr.refresh_rate_auto)
In big online games a client's ticks cost 3 to 5 ms (twice what bots or
smaller games do), and the frames with a tick missed 90 Hz's 11.1 ms:
the headset showed them twice, a doubled picture when turning, with the
GPU at 60-80 %. While more than one frame in sixteen comes late, two
two-second windows running, the rate now steps down to the next the
headset offers (90 to 72 Hz); after ten windows in which hardly any
would be late at the rate above, it steps back up, never above
vr.refresh_rate. A load's long frames count for neither. On by default,
"Lower it when frames are late" on the VR options' MORE page; nothing
where the headset offers one rate (SteamVR).

Also the BUTTONS page's notes below its ten rows (the push to talk row
pushed the last one onto them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:38:59 +02:00
12 changed files with 297 additions and 39 deletions

No files matched your search

+3 -2
View File
@@ -116,8 +116,9 @@ No computer and no keyboard needed.
library and offers to switch back to Gaming Mode.
6. Start **Ringfall: Mjolnir VR** from your library (under *Non-Steam*).
To update, open the same file in Desktop Mode again; your settings,
saves and game data stay. More about the Steam Frame:
The game updates itself: when a new version is out, it is downloaded
while you play and used from the next start on. Your settings, saves and
game data stay. More about the Steam Frame:
[port/android/STEAM_FRAME.md](port/android/STEAM_FRAME.md).
## Playing
+5 -2
View File
@@ -16,8 +16,11 @@ On the Frame, in Desktop Mode (Steam menu > Power > Switch to Desktop):
copies the game data out of it, adds "Ringfall: Mjolnir VR" to the Steam library
and switches back to Gaming Mode. Start it from the library there.
Opening the same file again updates the game (settings, saves and game data
stay).
The game updates itself: while it runs, its launcher looks for a newer
release, fetches it and puts it in place as the next start begins
(`steam_frame_launch.sh`; `update.auto = false` in `config.toml` stops it;
`launcher.log` says what it did). Opening the same file again updates it
too (settings, saves and game data stay).
The desktop file (`steam_frame_installer.desktop`) downloads the latest
release's `ringfall-mjolnir-vr-steam-frame.zip` (`tools/steam_frame_bundle.py`, made by
+32 -1
View File
@@ -17,7 +17,7 @@ named where code is taken over.
| Area | What |
| --- | --- |
| Headsets | Quest 3; Steam Frame (Lepton): its controllers' layout, a Steam library tile that installs, starts, updates and quits the game, data kept in `~/Documents/RingfallMjolnirVR` |
| Rendering | Both eyes in one pass (multiview) on the Quest; one by one on SteamVR, where multiview's eyes did not fuse; lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
| Rendering | The eyes drawn one by one (both at once, multiview, only with `debug.multiview`: its eyes did not always fuse, see the open points); lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
| Aiming | The weapon in the hand, its shots from the muzzle, laser and crosshair options |
| Body | Physical crouch (`vr.crouch_limit`) beside the crouch button, leaning (`vr.lean_limit`), melee by swinging, grenades thrown with the left hand, two-handed weapons |
| Vehicles | The Warthog's wheel turned with the hands, right trigger throttle, left brake; first-person seats; turrets with handles |
@@ -42,6 +42,7 @@ named where code is taken over.
| 3c | Weapons held physically, holsters at shoulder and hip (the buttons stay) | |
| 3d | Other VR players shown with their heads, hands and bodies (today their bodies move as flat players' do: only their aim and throws come from their hands) | |
| 4 | Later: disc image import in the launcher; moving by swinging the arms (in place of the stick, never faster than it: online too), and a sprint by swinging them harder (faster than the game allows: offline only) | import **done** (in `main` 2026-10-06): the app's import (tried on the Quest 3: all 24 maps; on the Frame kept in `~/Documents/RingfallMjolnirVR`), a Steam Frame installer from Desktop Mode (`STEAM_FRAME.md`; tried on the Frame: the old folder renamed, the shortcut added by Steam), a release signing key (CI and local builds the same). Arm swinging and its offline sprint still open |
| 5 | Later: multiview again (both eyes in one pass, half the drawing's CPU cost): find why its eyes do not fuse in big online games on the Quest 3 (and now and then on the Steam Frame), then turn it back on | open: off by default since 2026-10-16 (`debug.multiview`); findings in the open points |
| R | Release as **Ringfall: Mjolnir VR** (no "Halo" in the product's name): app name, id `io.github.cesarus85.ringfall`, icon and library pictures (`tools/ringfall_art.py`), Frame folder and installer, release assets, README; the repository renamed | **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good) |
## Step 2, first part (done)
@@ -518,6 +519,34 @@ see that nothing is missing or slower.
## Open points outside the order
- Multiview's doubled pictures (2026-10-16): on the developer's Quest 3,
doubled and shifted pictures from the start of big online games (26-35
players) as the head turned, nauseating; none in a local game with 48
bots; none online either with the eyes drawn one by one. Not the frame
rate: one by one, online at 72 Hz, the compositor repeated as many
frames (about 4 a second) and nothing was seen. Not the facing jumps
(respawns only) nor the camera (it moves every frame). Before: the
Steam Frame's (2026-10-05), each eye alone sharp. So the eyes are drawn
one by one by default now (`debug.multiview` for both at once). To do:
find what the shared pass draws wrongly online, e.g. by saving both
eyes' pictures in a big online game and comparing what is doubled
(sprites and effects projected by the game itself, `mv_depth`; programs
made on the shader worker; the sky rows), then turn it back on: it
halves the drawing's CPU cost.
- Big online games (2026-10-16): doubled pictures on a 32-player server,
none in a local game with 48 bots. Not the GPU (60–80 %): the ticks cost
3.3–4.6 ms as a client there (1.3–1.9 ms with bots or 16 players), so
47–70 of 150 frames with a tick missed 90 Hz's 11.1 ms (the doubling
itself was multiview's, above). In `main` 2026-10-16 (Quest 3 good):
`vr.refresh_rate_auto`
(VR options MORE, "Lower it when frames are late", on by default) steps
the rate down a step (90, 80, 72 Hz) while more than one frame in 32
comes late, and back up once they would keep up; "VR: refresh rate" in
the log. First try: 90 to 80 Hz a few seconds into a 30-player game, no
doubled picture seen; 2-5 stale frames a second left at 80, hence one
in 32 (was 16). Later:
why a client's ticks cost that much per remote player.
- Doubled pictures when turning on the developer's Quest 3 (2026-10-12):
with every build tried, back to 2026-10-06's, at 90 and at 72 Hz, even
in seconds the compositor reported no stale frame. That Quest runs
@@ -526,6 +555,8 @@ see that nothing is missing or slower.
default (smooth there). To do: try this and later builds with "high" on
a Quest 3 outside PTC (the developer's wife's), and set the default
back to "high" or "auto" if it holds.
Resolved 2026-10-16: multiview (above), not the Horizon OS PTC.
- Multiview on the Steam Frame: why the eyes do not fuse there (suspected
Mesa zink/Turnip); tools in branch `frame/doubled-images` (86335828),
not merged.
@@ -35,7 +35,7 @@ import java.nio.channels.FileChannel;
*/
public class LauncherActivity extends Activity {
private static final int PICK_IMAGE = 1;
private static final String SHARED_DATA_ROOT = "/home/steamos/Documents/RingfallMjolnirVR";
static final String SHARED_DATA_ROOT = "/home/steamos/Documents/RingfallMjolnirVR";
private File dataRoot;
private TextView status;
@@ -36,7 +36,8 @@ import java.util.zip.ZipInputStream;
* the latest release when it starts, on a thread of its own, and if it is
* newer asks the player whether to update:
*
* - Yes: the release's app (halo-android-release.zip or -debug.zip) is
* - Yes: the release's app (ringfall-mjolnir-vr-android-release.zip or
* -debug.zip) is
* downloaded and handed to Android's package installer, which replaces the
* game (closing it) and offers to open the new version.
* - No: nothing, until the next start.
@@ -45,6 +46,9 @@ import java.util.zip.ZipInputStream;
*
* Every build of main is signed with the same key (the workflow's), which an
* app must keep for Android to install a new version over it.
*
* Not on the Steam Frame: Lepton installs the APK in the game's folder there
* at every start, so its launcher updates that (steam_frame_launch.sh).
*/
final class Updater {
/* the VR port's own releases: the original's, made without VR, must
@@ -63,7 +67,8 @@ final class Updater {
static void start(Activity activity) {
File config = configFile(activity);
if (BuildConfig.HALO_BUILD_NUMBER <= 0 || config == null || !autoUpdate(config))
if (BuildConfig.HALO_BUILD_NUMBER <= 0 || config == null || !autoUpdate(config) ||
new File(LauncherActivity.SHARED_DATA_ROOT).isDirectory())
return;
new Thread(() -> {
int latest = latestRelease();
+4
View File
@@ -237,6 +237,10 @@ cp "$BUNDLE/$LAUNCHER" "$GAME_DIR/$LAUNCHER.tmp" && chmod 755 "$GAME_DIR/$LAUNCH
cp "$BUNDLE/$APK_NAME" "$GAME_DIR/$APK_NAME.tmp" && chmod 666 "$GAME_DIR/$APK_NAME.tmp" &&
mv -f "$GAME_DIR/$APK_NAME.tmp" "$GAME_DIR/$APK_NAME" && touch "$GAME_DIR/$APK_NAME" ||
fail "Could not write $GAME_DIR/$APK_NAME (is the storage full?)."
# (the build's number: the launcher updates from it, steam_frame_launch.sh;
# an update fetched for an older build is dropped)
cp "$BUNDLE/build.txt" "$GAME_DIR/build.txt" 2>/dev/null || rm -f "$GAME_DIR/build.txt"
rm -rf "$GAME_DIR/update"
# --- the game data
+72
View File
@@ -70,6 +70,75 @@ run()
child=0
}
# Updates (update.auto in config.toml; the game's own updater, the Quest's,
# does nothing here: Lepton installs the APK in this folder each start).
# While the game runs, the latest release is looked for; a newer one's
# bundle is fetched into update/, checked, and put in place as the next
# start begins, before Lepton runs the APK (it installs the newer one).
# A build that knows no number of its own (build.txt: a local one) never
# looks. Nothing changes without the internet, or when anything fails.
REPOSITORY="@REPOSITORY@"
UPDATE="$DATA/update"
BUILD_FILE="$DATA/build.txt"
SELF="$DATA/ringfall-mjolnir-vr.sh"
update_apply()
{
local new="$UPDATE/bundle" build
[ -f "$UPDATE/ready" ] || { rm -rf "$UPDATE"; return 0; }
build=$(cat "$new/build.txt" 2>/dev/null)
# (the launcher renamed over this one: bash goes on reading its own copy)
if cp "$new/ringfall-mjolnir-vr.sh" "$SELF.tmp" && chmod 755 "$SELF.tmp" &&
cp "$new/RingfallMjolnirVR.apk" "$APK.tmp" && chmod 666 "$APK.tmp" &&
mv -f "$SELF.tmp" "$SELF" && mv -f "$APK.tmp" "$APK" && touch "$APK" &&
cp "$new/build.txt" "$BUILD_FILE"
then
log "update: build $build put in place"
else
log "update: build $build could not be put in place"
rm -f "$SELF.tmp" "$APK.tmp"
fi
rm -rf "$UPDATE"
}
update_wanted()
{
# (update.auto = false in config.toml's [update]: never)
! awk '/^\[/ { section = $0 } section == "[update]" && /^[ \t]*auto[ \t]*=/ { print $3 }' \
"$DATA/config.toml" 2>/dev/null | grep -q false
}
update_check()
{
local installed latest
installed=$(cat "$BUILD_FILE" 2>/dev/null)
[[ "$installed" =~ ^[0-9]+$ ]] && (( installed > 0 )) || return 0
update_wanted || return 0
latest=$(curl -fsSL --max-time 20 -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPOSITORY/releases/latest" |
python3 -I -c 'import json, sys; print(json.load(sys.stdin)["tag_name"].rpartition("-")[2])' 2>/dev/null)
[[ "$latest" =~ ^[0-9]+$ ]] && (( latest > installed )) || return 0
log "update: build $latest found (this is $installed), fetching it"
rm -rf "$UPDATE" && mkdir -p "$UPDATE" || return 0
if curl -fsSL --retry 2 --max-time 900 -o "$UPDATE/bundle.zip" \
"https://github.com/$REPOSITORY/releases/download/build-$latest/ringfall-mjolnir-vr-steam-frame.zip" &&
python3 -I -m zipfile -e "$UPDATE/bundle.zip" "$UPDATE/bundle" &&
[ "$(cat "$UPDATE/bundle/build.txt" 2>/dev/null)" = "$latest" ] &&
[ -s "$UPDATE/bundle/RingfallMjolnirVR.apk" ] && [ -s "$UPDATE/bundle/ringfall-mjolnir-vr.sh" ]
then
rm -f "$UPDATE/bundle.zip"
touch "$UPDATE/ready"
log "update: build $latest fetched, put in place at the next start"
else
log "update: build $latest could not be fetched"
rm -rf "$UPDATE"
fi
}
update_apply
update_check </dev/null >/dev/null 2>&1 &
updater=$!
# (a new APK is only installed on its first start, not started; and a start
# right after a game that was ended hard can fail in SteamVR, which then
# needs a moment: again, up to three times, while it ends within 30 s)
@@ -83,4 +152,7 @@ for attempt in 1 2 3; do
sleep 5
done
rm -f "$QUIT"
# (a fetch still under way is given up: the next start looks again)
pkill -P "$updater" 2>/dev/null
kill "$updater" 2>/dev/null
log "end"
+1 -1
View File
@@ -50,7 +50,7 @@ void halo_vr_eye_camera(int eye, const float game_position[3],
/* copies the back buffer, just drawn for an eye, to that eye's image */
void halo_vr_capture_eye(int eye);
/* Both eyes in one draw (vr.multiview, where GL_OVR_multiview2 is): whether
/* Both eyes in one draw (debug.multiview, where GL_OVR_multiview2 is): whether
this frame draws so; the camera between the eyes whose view takes in both
(as halo_vr_eye_camera), which also starts drawing into both eyes' layers;
and after the draw, the layers taken for the eyes' images. */
+12 -7
View File
@@ -200,6 +200,12 @@ static const struct config_setting config_settings[] =
{ "vr.refresh_rate", _config_real, "90.0", "HALO_VR_REFRESH_RATE", _environment_value, _platform_android,
"The display refresh rate to ask the headset for, in Hz (72, 90, 120);\n"
"it picks the nearest it has at or below. 0 for the highest." },
{ "vr.refresh_rate_auto", _config_boolean, "true", "HALO_VR_REFRESH_RATE_AUTO", _environment_value,
_platform_android,
"While frames come late at that rate (big online games), step down to the\n"
"next the headset has (90 to 72 Hz), and back up once they would keep up\n"
"again; the headset shows a late frame twice, a doubled picture when\n"
"turning. Only where the headset lets the game choose (not SteamVR)." },
{ "vr.graphics", _config_string, "\"medium\"", "HALO_VR_GRAPHICS", _environment_value, _platform_android,
"Which of the costlier effects are drawn (\"medium\" by default): \"auto\" all of them while the\n"
"GPU keeps up, leaving out one after another (the shield's glow, object\n"
@@ -227,9 +233,6 @@ static const struct config_setting config_settings[] =
"away from the player's body (walls are not checked)." },
{ "vr.crouch_limit", _config_real, "0.9", "HALO_VR_CROUCH_LIMIT", _environment_value, _platform_android,
"How far, in metres, the view follows your head down." },
{ "vr.multiview", _config_boolean, "true", "HALO_VR_MULTIVIEW", _environment_value, _platform_android,
"Draw both eyes in one pass (GL_OVR_multiview2), from between them: about\n"
"half the drawing's cost on the processor. false: one eye after the other." },
{ "vr.log_performance", _config_boolean, "false", "HALO_VR_LOG_PERFORMANCE", _environment_value, _platform_android,
"Log the frame rate and where the frame time goes every five seconds\n"
"(adb logcat -s halo)." },
@@ -780,10 +783,12 @@ static const struct config_setting config_settings[] =
"machines of twice it), to test the netcode as over the internet; 0 none." },
{ "debug.network_loss", _config_real, "0.0", "HALO_NETWORK_LOSS", _environment_value, _platform_all,
"Percent of datagrams received that are dropped, for the same; 0 none." },
{ "debug.multiview_steamvr", _config_boolean, "false", "HALO_MULTIVIEW_STEAMVR", _environment_value,
_platform_android,
"On SteamVR (the Steam Frame) the eyes are drawn one by one, whatever\n"
"vr.multiview says: true draws them at once there too, for testing." },
{ "debug.multiview", _config_boolean, "false", "HALO_MULTIVIEW", _environment_value, _platform_android,
"Draw both eyes in one pass (GL_OVR_multiview2), from between them: about\n"
"half the drawing's cost on the processor, but the eyes' pictures did not\n"
"always fuse (doubled as the head turned: on the Steam Frame now and then,\n"
"on the Quest 3 in big online games). Off, one eye after the other; on,\n"
"for finding why." },
{ "debug.gl_thread", _config_integer, "-1", "HALO_GL_THREAD", _environment_value, _platform_android,
"Mesa's GL thread (its drivers: the Steam Frame's): 1 on, 0 off, -1 as the\n"
"driver chooses. Read when the app starts." },
+119 -14
View File
@@ -138,7 +138,7 @@ static struct
float seat_frame_yaw;
int seat_frame_valid;
float seat_turn;
int multiview; /* vr.multiview: both eyes in one draw */
int multiview; /* debug.multiview: both eyes in one draw */
int multiview_logged;
int multiview_logged_views;
/* vr.foveation's level last logged (+1; 0: none yet) */
@@ -298,6 +298,15 @@ static struct
vr.resolution_scale as the app started with it */
float refresh_rate_wanted;
float resolution_scale;
/* vr.refresh_rate_auto (refresh_governor): the rate asked for now (the
one wanted, or lower while frames come late), the rates below and above
it, and the window's frames: all, late at this rate, and those that
would be late at the one above */
int refresh_auto;
float refresh_rate_now, refresh_lower, refresh_higher, refresh_period_ms;
unsigned long long refresh_window_us, refresh_changed_us;
unsigned long refresh_frames, refresh_late, refresh_tight;
int refresh_bad_windows, refresh_good_windows;
} vr;
/* d3d8_gl.c */
@@ -585,12 +594,23 @@ static void vr_read_live_settings(void)
/* (the refresh rate changes at once where the runtime lets it) */
float refresh_rate = (float)config_real("vr.refresh_rate");
if (vr.active && refresh_rate != vr.refresh_rate_wanted)
int automatic = config_boolean("vr.refresh_rate_auto");
if (vr.active && (refresh_rate != vr.refresh_rate_wanted ||
(!automatic && vr.refresh_rate_now != refresh_rate)))
{
platform_log("VR: refresh rate %.0f Hz asked for", refresh_rate);
host_xr_set_refresh_rate(refresh_rate);
}
if (refresh_rate != vr.refresh_rate_wanted || automatic != vr.refresh_auto)
{
/* (the governor starts over from the rate chosen) */
vr.refresh_rate_now = refresh_rate;
vr.refresh_window_us = 0;
vr.refresh_bad_windows = vr.refresh_good_windows = 0;
}
vr.refresh_rate_wanted = refresh_rate;
vr.refresh_auto = automatic;
}
}
@@ -625,7 +645,7 @@ void halo_vr_initialize(void)
vr.two_hand_reach = (float)config_real("vr.two_hand_reach");
vr.melee_swing_speed = (float)config_real("vr.melee_swing_speed");
vr.grenade_throw_speed = (float)config_real("vr.grenade_throw_speed");
vr.multiview = config_boolean("vr.multiview");
vr.multiview = config_boolean("debug.multiview");
vr.vehicle_first_person = config_boolean("vr.vehicle_first_person");
vr.vehicle_spawn = config_boolean("debug.vehicle_spawn");
vr.turret_handles = config_boolean("vr.turret_handles");
@@ -658,16 +678,13 @@ void halo_vr_initialize(void)
vr.active = 1;
platform_log("VR: %s, eyes %dx%d at %.0f Hz", vr.info.system_name, vr.info.eye_width, vr.info.eye_height,
vr.info.refresh_rate);
/* SteamVR (the Steam Frame): each eye drawn apart. With both drawn at
once the eyes' pictures did not fuse, now and then: each alone was
sharp, the two together doubled as the head turned (2026-10-05), and
one by one costs it no more (its time goes to the driver either way).
debug.multiview_steamvr draws them at once there again, for finding why */
if (vr.multiview && strstr(vr.info.system_name, "SteamVR") && !config_boolean("debug.multiview_steamvr"))
{
vr.multiview = 0;
platform_log("VR: SteamVR: the eyes drawn one by one (debug.multiview_steamvr)");
}
/* Each eye drawn apart (debug.multiview draws both at once). At once,
the eyes' pictures did not always fuse: on the Steam Frame now and then
(each alone sharp, the two together doubled as the head turned,
2026-10-05; one by one costs it no more there), and on the Quest 3 in
big online games (doubled from the start as the head turned, none with
48 bots; one by one, none online either, 2026-10-16) */
platform_log("VR: the eyes drawn %s (debug.multiview)", vr.multiview ? "at once" : "one by one");
}
int halo_vr_active(void)
@@ -889,6 +906,93 @@ static void performance_phases(unsigned long long now)
vr.frame_ticks = 0;
}
/* vr.refresh_rate_auto: frames late at the headset's rate (a game's big
online games: the ticks cost as a client in step with the players) are
shown twice by the headset, a doubled picture when turning. While more
than one in 32 of them come late, two windows of REFRESH_WINDOW_US
running, the rate steps down to the next the headset offers (the Quest
3's: 90, 80, 72 Hz); while hardly any would be late at the next one up (one in a
hundred), ten windows running, it steps back up, never above
vr.refresh_rate. Frames of a load (REFRESH_LOAD_MS and more) count for
neither; a step waits REFRESH_SETTLE_US (down) or REFRESH_RAISE_US (up)
after the last. */
#define REFRESH_WINDOW_US 2000000ULL
#define REFRESH_SETTLE_US 10000000ULL
#define REFRESH_RAISE_US 30000000ULL
#define REFRESH_LOAD_MS 50.0f
static void refresh_rates_around(void)
{
float rates[16], current = 0.0f;
int count = host_xr_refresh_rates(rates, 16, &current);
float ceiling = vr.refresh_rate_wanted > 1.0f ? vr.refresh_rate_wanted + 0.5f : 1e6f;
int index;
vr.refresh_lower = vr.refresh_higher = 0.0f;
if (current <= 1.0f)
current = vr.refresh_rate_now;
vr.refresh_period_ms = current > 1.0f ? 1000.0f / current : 0.0f;
for (index = 0; index < count; index++)
{
if (rates[index] < current - 0.5f && rates[index] > vr.refresh_lower)
vr.refresh_lower = rates[index];
if (rates[index] > current + 0.5f && rates[index] <= ceiling &&
(!vr.refresh_higher || rates[index] < vr.refresh_higher))
vr.refresh_higher = rates[index];
}
}
static void refresh_step(float rate, unsigned long long now, const char *why)
{
platform_log("VR: refresh rate %.0f Hz (from %.0f): %s (%lu of %lu frames late, %lu too slow for the rate above)",
rate, 1000.0f / vr.refresh_period_ms, why, vr.refresh_late, vr.refresh_frames, vr.refresh_tight);
host_xr_set_refresh_rate(rate);
vr.refresh_rate_now = rate;
vr.refresh_changed_us = now;
vr.refresh_bad_windows = vr.refresh_good_windows = 0;
}
static void refresh_governor(void)
{
unsigned long long now = now_us();
float work_ms = (float)(now - vr.frame_start_us) * 1e-3f;
if (!vr.refresh_auto)
return;
if (!vr.refresh_window_us)
{
refresh_rates_around();
vr.refresh_window_us = now;
vr.refresh_frames = vr.refresh_late = vr.refresh_tight = 0;
return;
}
if (work_ms < REFRESH_LOAD_MS && vr.refresh_period_ms > 0.0f)
{
vr.refresh_frames++;
if (work_ms + 1.0f > vr.refresh_period_ms)
vr.refresh_late++;
if (vr.refresh_higher > 1.0f && work_ms + 1.0f > 1000.0f / vr.refresh_higher)
vr.refresh_tight++;
}
if (now - vr.refresh_window_us < REFRESH_WINDOW_US)
return;
if (vr.refresh_frames)
{
vr.refresh_bad_windows = vr.refresh_late * 32 > vr.refresh_frames ? vr.refresh_bad_windows + 1 : 0;
vr.refresh_good_windows = vr.refresh_tight * 100 <= vr.refresh_frames ? vr.refresh_good_windows + 1 : 0;
if (vr.refresh_lower > 1.0f && vr.refresh_bad_windows >= 2 && now - vr.refresh_changed_us >= REFRESH_SETTLE_US)
refresh_step(vr.refresh_lower, now, "frames come late");
else if (vr.refresh_higher > 1.0f && vr.refresh_good_windows >= 10 &&
now - vr.refresh_changed_us >= REFRESH_RAISE_US)
{
refresh_step(vr.refresh_higher, now, "frames would keep up again");
}
}
refresh_rates_around();
vr.refresh_window_us = now;
vr.refresh_frames = vr.refresh_late = vr.refresh_tight = 0;
}
static void performance_report(int stereo)
{
unsigned long long now = now_us();
@@ -1886,6 +1990,7 @@ void halo_vr_present(unsigned int back_buffer_texture)
vr.last_overlay_mode = vr.overlay_mode;
vr.last_frame_stereo = submit.eyes;
vr.frame_begun = 0;
refresh_governor();
if (vr.log_performance)
performance_report(submit.eyes);
}
@@ -2053,7 +2158,7 @@ float halo_vr_refresh_rate(void)
return vr.active ? vr.info.refresh_rate : 0.0f;
}
/* ---------- both eyes at once (vr.multiview)
/* ---------- both eyes at once (debug.multiview)
The scene is drawn once, from a camera between the eyes whose view takes in
both eyes' (with a margin, for what is near: the eyes stand apart), into two
+26 -7
View File
@@ -7468,6 +7468,7 @@ enum
_vr_option_graphics,
_vr_option_foveation,
_vr_option_refresh_rate,
_vr_option_refresh_auto,
_vr_option_resolution,
_vr_option_controls,
_vr_option_quit,
@@ -7521,8 +7522,8 @@ static short const vr_option_pages[NUMBER_OF_VR_OPTION_PAGES][VR_OPTIONS_PER_PAG
_vr_option_button_first + HALO_VR_ACTION_CROUCH, _vr_option_button_first + HALO_VR_ACTION_ZOOM,
_vr_option_button_first + HALO_VR_ACTION_MELEE, _vr_option_button_first + HALO_VR_ACTION_TALK,
_vr_option_buttons_reset, _vr_option_controls, NONE, NONE },
{ _vr_option_graphics, _vr_option_foveation, _vr_option_refresh_rate, _vr_option_resolution, _vr_option_quit,
NONE, NONE, NONE, NONE, NONE, NONE, NONE },
{ _vr_option_graphics, _vr_option_foveation, _vr_option_refresh_rate, _vr_option_refresh_auto,
_vr_option_resolution, _vr_option_quit, NONE, NONE, NONE, NONE, NONE, NONE },
};
/* the page a row is on, and its place there */
@@ -7942,6 +7943,9 @@ static void ui_vr_options_change(
}
break;
}
case _vr_option_refresh_auto:
config_write_boolean("vr.refresh_rate_auto", !config_boolean("vr.refresh_rate_auto"));
break;
case _vr_option_resolution:
ui_vr_options_step("vr.resolution_scale", vr_resolution_scales, NUMBEROF(vr_resolution_scales), change);
break;
@@ -8651,7 +8655,10 @@ static void ui_vr_options_render(
short count = ui_vr_options_refresh_rates(rates, &chosen, &current);
label = "Refresh rate";
if (count > 0)
/* (lowered while frames come late: vr.refresh_rate_auto) */
if (count > 0 && current > 1.0f && current < rates[chosen] - 0.5f)
snprintf(value, sizeof(value), "%.0f Hz (now %.0f)", rates[chosen], current);
else if (count > 0)
snprintf(value, sizeof(value), "%.0f Hz", rates[chosen]);
else if (current > 1.0f)
snprintf(value, sizeof(value), "%.0f Hz (system)", current);
@@ -8660,6 +8667,17 @@ static void ui_vr_options_render(
unused = count <= 1;
break;
}
case _vr_option_refresh_auto:
{
/* (dim where the headset offers one rate: SteamVR's) */
real rates[16], current;
short chosen;
label = "Lower it when frames are late";
snprintf(value, sizeof(value), "%s", config_boolean("vr.refresh_rate_auto") ? "On" : "Off");
unused = ui_vr_options_refresh_rates(rates, &chosen, &current) <= 1;
break;
}
case _vr_option_resolution:
{
real scale = vr_resolution_scales[ui_vr_options_nearest((real)config_real("vr.resolution_scale"),
@@ -8717,12 +8735,11 @@ static void ui_vr_options_render(
}
if (page == _vr_options_page_buttons)
{
/* (what the buttons' rows do) */
ui_join_code_draw_text(128, 512, 302, "An action moved to a button swaps with the one there;", FALSE, 0,
/* (what the buttons' rows do: below the page's ten rows) */
ui_join_code_draw_text(128, 512, 320, "An action moved to a button swaps with the one there;", FALSE, 0,
&dim_color);
ui_join_code_draw_text(128, 512, 320, "left-handed and mirrored, the buttons are mirrored too.", FALSE, 0,
ui_join_code_draw_text(128, 512, 338, "mirrored, the buttons are too. The menus keep theirs.", FALSE, 0,
&dim_color);
ui_join_code_draw_text(128, 512, 338, "The menus keep their buttons.", FALSE, 0, &dim_color);
}
if (page == _vr_options_page_more)
{
@@ -8739,6 +8756,8 @@ static void ui_vr_options_render(
&dim_color);
ui_join_code_draw_text(128, 512, 320, "Foveation draws the view's edges coarser (Quest only).", FALSE, 0,
&dim_color);
ui_join_code_draw_text(128, 512, 338, "Late frames: the rate steps down (90, 80, 72) while they last.", FALSE, 0,
&dim_color);
}
ui_join_code_draw_text(126, 514, VR_OPTIONS_FOOTER + 8, "%a-button Change %b-button Done", TRUE, 0, &text_color);
}
+15 -2
View File
@@ -7,13 +7,17 @@ The Steam Frame's installer as release assets (port/android/STEAM_FRAME.md):
writes into the output folder
- ringfall-mjolnir-vr-steam-frame.zip: the installer (port/android/steam_frame_install.sh,
as install.sh), the APK (RingfallMjolnirVR.apk), the launcher
(steam_frame_launch.sh, as ringfall-mjolnir-vr.sh), the disc image reader (steam_frame_xiso.py, with
(steam_frame_launch.sh, as ringfall-mjolnir-vr.sh, which updates the game
from the repository named), the build's number (build.txt: the workflow's
run number, HALO_BUILD_NUMBER or GITHUB_RUN_NUMBER; 0, which never
updates, without one), the disc image reader (steam_frame_xiso.py, with
extract-xiso's license) and the library pictures (grid/);
- Install-Ringfall-Mjolnir-VR.desktop (port/android/steam_frame_installer.desktop): what
the player downloads and opens in Desktop Mode; it fetches the latest
release's zip from the repository named.
"""
import os
import sys
import zipfile
from pathlib import Path
@@ -38,13 +42,22 @@ def main():
]
files += [(picture, "grid/" + picture.name, 0o644)
for picture in sorted((ANDROID / "steam_frame_grid").glob("*.png"))]
build = os.environ.get("HALO_BUILD_NUMBER") or os.environ.get("GITHUB_RUN_NUMBER") or "0"
if not build.isdigit():
build = "0"
bundle = output / "ringfall-mjolnir-vr-steam-frame.zip"
with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as archive:
for path, name, mode in files:
info = zipfile.ZipInfo.from_file(path, name)
info.external_attr = (0o100000 | mode) << 16
info.compress_type = zipfile.ZIP_STORED if name.endswith((".apk", ".png")) else zipfile.ZIP_DEFLATED
archive.writestr(info, path.read_bytes())
data = path.read_bytes()
if name == "ringfall-mjolnir-vr.sh":
data = data.replace(b"@REPOSITORY@", repository.encode())
archive.writestr(info, data)
info = zipfile.ZipInfo("build.txt", date_time=(2026, 1, 1, 0, 0, 0))
info.external_attr = (0o100000 | 0o644) << 16
archive.writestr(info, build + "\n")
print(f"{bundle.relative_to(Path.cwd()) if bundle.is_relative_to(Path.cwd()) else bundle}")
desktop = (ANDROID / "steam_frame_installer.desktop").read_text().replace("@REPOSITORY@", repository)