The updater's receiver starts the game on MY_PACKAGE_REPLACED. On a
shared headset every install of the VR APK then launched Halo in a new
task, and SteamVR quit whichever scene app was running for it. The VR
flavor's manifest removes the receiver.
At 1.5x the GPU needed 11.5 ms a frame (timestamps, vr.timing_gpu_stamps),
over 90 Hz's 11.1, almost all of it the eyes' pixels. The runtime's
visibility mask (XR_KHR_visibility_mask, 15.6% of each eye's image on the
Frame) is now drawn first in each eye's pass at the near plane, black, so
the scene is not shaded there (vr.visibility_mask, on). Two things it took:
the device binds its target and viewport lazily, so the mask binds the
eye's first (halo_vr_bind_current_target); and its depth goes through the
position, not gl_FragDepth, which turned Adreno's early depth culling off
for the rest of the pass (it saved 0.3 ms that way, 1.5 ms this way).
On the device in a30 at 1.5x: the GPU's frame from 11.5 to 10.0 ms (the
eyes 4.4/5.1 to 3.6/4.1), 85.5 fps with 45 misses in 10 s to 89.4 with 2
to 5. Cutscenes (the 3D screen, not masked) hold 78 to 89.
Also: vr.timing_gpu_passes (each pass waited for; Zink's glFinish costs
too much for it to mean much), glQueryCounterEXT and
glGetQueryObjectui64vEXT for the guest, the mask's share logged.
xrSyncActions looks up every bound input of every action each frame,
through SteamVR's property maps: 9.7% of the game thread in a30. The ten
Xbox-layout button actions (A, B, X, Y, white, black, start, back, the
stick clicks) duplicated what the per-hand actions already read, with 25
bindings over the three profiles; they are gone, and vr.controls "pad"
takes its buttons from each hand's (the Frame's right four face buttons,
or on Touch and Index the right's two and the left's two; bumpers or their
stand-ins; Menu; the Frame's View; the stick clicks). The d-pad is asked
once a frame, not twice, and hand buttons no profile binds on a hand are
not asked for.
Twice today SteamVR refused the instance at launch (XR_ERROR_RUNTIME_FAILURE,
the headset asleep), the game fell back to drawing on its Android window,
SurfaceFlinger refused those frames (1920x984 against a 1920x1080 window),
and about 20 s later the display composer aborted, then SurfaceFlinger,
and the whole Lepton container went down. The host now retries the
instance once a second for a minute; if there is still no runtime, the
Android build logs why and quits rather than drawing flat.
host_syscall served the guest's clock_gettime with a raw syscall(),
bypassing the vDSO: every QueryPerformanceCounter and system_milliseconds
went through the kernel and Lepton's seccomp filter. The game read the
clock about 535,000 times a second in a30 (Halo's profiler, mostly); on
the device host_syscall fell from 12.6% to 9.8% of the game thread's
cycles with this, to 1.3% with the profiler's texture timer gated too.
SteamVR's GL binding makes its own context current for every swapchain
acquire, wait and release (and the game's again after), each a flush,
fences and trace markers in Zink: on the device they were a fifth of the
game thread's cycles. The eyes and the quad (HUD or flat screen) are now
parts of one atlas swapchain, acquired by whichever part comes first and
released as the frame ends; the layers take their parts' rectangles
(halo_xr_info.x, .y). Every layer shown is still drawn the frame it is
shown, as a new image of the atlas is each frame's.
On the device at 90 Hz and 1.0x: a30 play from 10.3 to 7.8-9.9 ms of CPU
a frame, 84-90 to 87-90 fps; a10's opening cutscene at 89-90 fps. Dumps of
a stereo frame (eyes, HUD) and a cutscene frame (the 3D screen's eyes)
show each part where it belongs.
OpenXR wants each action's localized name unique in its set. button_start
and hand_menu shared "Menu (pause; Index: a firm press of a trackpad)", so
creating hand_menu failed (XR_ERROR_LOCALIZED_NAME_DUPLICATED), no actions
were attached and no controller reached the game: the menus took no input.
On the device the actions are now made and all three profiles' bindings
suggested (seen in the log; not yet worn).
Menu was bound to the Index controller's system button, which the runtime
keeps, so Index had no way to pause. It is now either trackpad's force
(a float on a boolean action, read as pressed past the runtime's
threshold), as a chord of existing inputs would have conflicted with the
standard's rule against stick-click chords and with every free button.
system_layer takes its timing from sfvr_view_update (the same 1 s and 3 s
holds and short press, now on the real clock, and a press cut by losing
focus is dropped). Buzzes are submitted to an sfvr_haptic_queue (vr_haptic
keeps its signature) and flushed once a frame in vr_present at vr.haptics'
strength, overlapping ones coalescing into the strongest; the system buzz
is SFVR_HAPTIC_SYSTEM. The right stick's turn is sfvr_turn_update, from
vr.turn_mode, vr.snap_angle and vr.smooth_turn_speed (snap 30 and smooth
120 stay the defaults; smooth now has sfvr's 0.2 dead zone and rescaling,
its sign converted to the heading's).
The guest logs the standard [vr-perf] line every 10 s (2 s with vr.timing)
from an sfvr_perf, with the stages game, render, copy and end, each eye's
pass, the GPU wait with vr.timing_gpu, and frames missed counted from the
display times; it replaces the old [vr-frame] line. The host's own
statistics line (which drives the 72 Hz fallback) is now an [openxr] line.
Touch and Index have no bumpers, so the VR layout's grenade (weapon hand's
bumper) and flashlight (the other's) had no input. The host now watches
which controller each hand holds (the interaction profile changed event)
and, for one without a bumper, reports a grip held with a stick click as
that hand's bumper; the stick click alone still melees and crouches. The
Frame's own bumpers are untouched.
The action labels, which SteamVR shows in its bindings, no longer name the
Xbox pad's roles as if they were the VR layout's: the pad-layout actions
say so, and the per-hand ones name their VR roles, View included.
The tenth item of the VR settings page (the right box's last row). A
asks "SURE? A TO QUIT"; A again within five seconds asks the runtime to
end the session (a new host import, host_xr_request_exit, calling
xrRequestExitSession) and exits when its STOPPING and EXITING states have
come, which the frame loop already follows, or after three seconds if
they never do. Left, another item or the time puts it back. It adds no
structure to the ABI, so there is no layout to assert.
View (the left controller's) keeps its short press as back and its 1 s
hold as a recentre with a buzz in both hands, but that recentre now takes
the head's yaw and horizontal position and keeps the standing height
(host_xr_recenter takes HALO_XR_RECENTRE_HEIGHT). A new 3 s hold takes the
height too, which is what crouching is measured from, with a second
buzz. The hold works in the Xbox-pad layout as well, whose back becomes
the short press.
The both-stick-clicks recentre is gone (it also fired crouch and melee).
A recentre brings the flat and cutscene screens back ahead of the head,
and the host handles XrEventDataReferenceSpaceChangePending as a recentre
with height, once the runtime has moved the space.
While a hand-aimed weapon is zoomed, a pass between the eyes and the HUD
renders the view along the gun into a corner of the back buffer, sized
to the scope's 768x768 image. It goes through a sight mask (round, or
the sniper rifle's wide one) onto a layer held at the gun, at the PC
mod HaloCEVR's per-weapon offsets. The eyes never zoom: the first-person
gun stays in the hand and the HUD layer drops the zoom mask.
vr.scope and vr.scope_size are the settings; vr.diag_zoom_seconds zooms
unattended for dumps (vr-scope.bmp). The docs now cover the VR controls,
gestures, weapon feel and scope, and credit LivingFray's HaloCEVR for
their designs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the designs of LivingFray's PC mod HaloCEVR, re-implemented here.
vr.controls "vr" (the default) lays the headset's controllers out for VR
over the Xbox pad the game reads: right trigger fire, left trigger zoom,
A jump, B reload and action, X switch grenades, Y switch weapons, right
bumper grenade, left bumper flashlight, right stick click melee, left
stick click crouch, menu pause, view back, held a second to recentre.
"pad" keeps the Xbox layout. The host now reports each controller's own
buttons (hand_buttons) beside the pad it built.
Gestures, taken into the game's own control flags in
handle_one_player_input (before a cutscene's inhibition clears them):
- a hand swung up or down faster than vr.melee_speed (2.5 m/s) melees;
- the off hand brought within vr.flashlight_distance (0.2 m) of the
head's middle toggles the flashlight;
- ducking vr.crouch_height (0.15 m) below the recentred height crouches,
standing again within 5 cm;
- the weapon hand's grip at either shoulder (vr.holsters) switches
weapons, with a tick as the hand enters a holster;
- the off hand's grip holds the gun in both hands (vr.two_handed "grip",
within 80 cm; "auto" keeps the earlier proximity test);
- palms together and the off hand's grip moves the weapon to that hand.
vr.left_handed (or that swap) puts the weapon in the left hand: the
weapon hand is a variable throughout, its trigger fires and its bumper
throws grenades, and the first-person model is mirrored across the
weapon camera's upright plane with its winding turned over; the IK then
reaches the gun arm from the left shoulder. vr.move_relative moves
relative to the head or a hand.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cutscenes play on a 3D screen: each eye's view from the cutscene's camera
moved half the eye separation aside (vr.cinema_separation), its frustum
turned in to converge at vr.cinema_convergence, the console window's
letterbox and titles drawn over it, shown as a quad layer each eye sees
its own of (vr.cinema_distance, vr.cinema_width). A screen (menus,
loading, cutscenes) comes up where the head faces when it appears, and
every change of what is shown comes in from black (a quarter second
black, then a 0.35 s fade), which also hides the few frames of play a
level shows before its opening cutscene.
The headset asks for vr.refresh_rate, now 90 Hz (XR_FB_display_refresh_rate),
and falls back to 72 if the game does not hold 90% of it for 20 seconds
while worn. The game thread measured busy 59% of a 72 Hz frame (8.3 ms).
The renderer's stream and index buffers are persistently mapped where the
driver has GL_EXT_buffer_storage, so an upload is a copy with no GL call
(on Zink each map synchronized the threaded context): each eye's pass went
from 3.2 to 2.2 ms. A full buffer within a frame waits for the GPU
instead of orphaning, which immutable storage cannot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading the visibility counters still waited, once a frame, for the GPU
to finish all queued work, so a frame's time was the CPU's plus the
GPU's: raising the headset's resolution to 1.25 dropped the first level
to 37 frames a second. Each frame's counters are now copied, behind the
frame fence the stream ring already uses, into a buffer of that ring
slot, and read once the GPU has passed it (host_gl_frame_done, without
waiting); a test's result is the latest the GPU has finished, a frame or
two old, which lens flares do not show. At 1.25 (2160x2160 an eye) the
second level now runs stereo at the headset's 72 frames a second.
Valve's Mesa in Lepton writes a kernel trace marker around every flush
and context switch, a couple of hundred a frame; the descriptors it
opens on trace_marker are pointed at /dev/null once the GL context and
the OpenXR session exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Android renderer answered each visibility test (lens-flare
occlusion) by mapping the counter buffer for its one word, and each map
waits for the GPU's queue to drain; on the Steam Frame's Zink it also
flushes and logs a performance warning. In stereo, with the flare tests
of two eyes, that took 45% of the game thread: 13-20 frames a second in
the first level's cryo bay. All counters are now read together into a
copy the first time a result is asked for after a test ends (one wait a
frame), and the same scene runs at the headset's 72.
The headset's HUD pass took the planar fog, whose animation offset only
a structure pass consumes, and the next frame's assertion stopped the
game in fogged clusters; the HUD pass leaves the fog alone.
In VR the head's turns count as looking for the scripts that wait for
the player to look (the first level's look test).
The host's thread sampler takes fractions of a second.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is staged in build/android/jniLibs-vr, which only the vr flavour
packages, instead of beside the flat build's libraries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With vr.aim "hand", on foot the right controller's aim sets the
player's facing, so shots, grenades and melee follow it; in a vehicle's
or a turret's seat the head still aims. The left stick is turned to move
relative to the head rather than the aim. In a local game the shots
start at the hand (unit_adjust_projectile_ray) unless a wall stands
between it and the unit's eye.
The first-person weapon is posed from a camera placed so that its model
sits in the hand (vr.weapon_offset_*, to tune in the headset). The HUD
crosshair gives way to a reticle on its own OpenXR layer where the
hand's aim meets the world, about a degree across.
Head aiming stays the default until this is checked in the headset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lepton's app storage goes with a container reset; the headset's
Documents folder (Android's /sdcard/Documents) stays. The VR build
prefers game data in Documents/HaloCE, with its settings and saves
beside it.
tools/steam_frame/extract_maps.py copies maps out of a disc image on the
Mac (as port/linux/src/xiso.c reads it); deploy.sh copies them to the
headset over SSH, edits config.toml settings, installs and starts the
app over Lepton's adb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the VR build the game draws the Xbox's 640x480 at the eyes' resolution
(vr.resolution_scale of the runtime's recommendation) and D3DDevice_Present
copies each frame into a quad floating ahead (vr.screen_distance,
vr.screen_width) instead of swapping the window, so OpenXR's frame timing
paces the game. The sRGB swapchain is written without conversion
(GL_EXT_sRGB_write_control). The headset's controllers merge into the
first pad.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure.py --vr builds com.halo.decomp.vr: the host gains host_xr.c,
which owns the Khronos loader (1.1.63, fetched from Maven Central and
checked by SHA-256), the instance, a GLES session on the game's EGL
context, two eye swapchains and a quad, and controller actions for the
Steam Frame, Touch and Index profiles. The guest drives it through seven
imports (host_imports_vr.list) with fixed-layout structures in
halo_android_abi.h, checked on both ABIs.
vr.probe_seconds shows dim colours in each eye before the game starts.
On the Steam Frame (Lepton) it reaches SYNCHRONIZED and holds 72 frames
a second; the Frame controller profile binds.
SDL3's Java side assumed a clipboard service, which Lepton lacks; a
patch (port/android/patches) applied at configure time checks for it.
The update provider's authority follows the application id, so the
flat and VR apps install side by side.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the Retroid Pocket Flip2 (kona, kernel 4.19) ART reserves its free-list large object space across 0x754d0000-0x954d0000 at zygote start, covering the fixed Xbox window at 0x80000000, so MAP_FIXED_NOREPLACE fails with EEXIST and the game image never loads.
When the fixed reservation hits EEXIST, munmap exactly the intersection of the requested range with anonymous [anon:dalvik-*] space reservations (idle: those spaces commit lazily from their bottom and this app allocates no large objects) and retry. Nothing changes when the window is already free.
The Android port writes each streamed vertex and index range with a
glMapBufferRange call that set both GL_MAP_UNSYNCHRONIZED_BIT and
GL_MAP_INVALIDATE_RANGE_BIT. The two contradict each other: the first
promises that no queued draw is reading the range, the second tells the
driver to discard whatever the range held, which is the work the first exists
to avoid.
Adreno charges for that discard, around 0.85 ms per call whatever the range
written, and the renderer makes one call per draw. On a Galaxy Z Flip 4
(Snapdragon 8 Gen 1) a few hundred of those a frame came to 96% of a 550 ms
frame and left the game running at 1.8 fps with the GPU idle the whole time.
Dropping the invalidation takes the same call to well under a microsecond and
the same game to the display's refresh rate.
Mali is unaffected either way, and its memory use is flat before and after.