55 KiB
WipEout VR: architecture
This is a native Steam Frame VR port built on phoboslab/wipeout-rewrite. It targets SteamOS with SteamVR OpenXR, SDL2 and OpenGL through Turnip and Zink. The VR build renders races in stereo and adds Frame controller input; title, menu and intro screens use a world-locked panel.
The cockpit instrument view is in development. HUD distance and visibility controls are not implemented, and the desktop mirror is blank. The source-rights hold and data requirements are described in README.md.
1. Target and base
| Item | Value |
|---|---|
| Device | Steam Frame, SteamOS vr, SteamVR OpenXR, Turnip + Zink GL 4.6 |
| Package route | Linux ARM64 ELF, compat tool SteamLinuxRuntime_4-arm64, devkit title WipEoutVR |
| Build flag | CMake -DWIPEOUT_VR=ON defines WIPEOUT_VR=1 (default OFF). With the flag off, the flat build is unchanged (section 9 has the proof) |
| GL profile | Core 4.3 context (SDL requests it, forward-compatible), with GLSL 330 variants of the shaders in src/vr/vr_shaders_gl330.h. See section 9 |
| Base | wipeout-rewrite: C11, SDL2 + OpenGL 2.x/GLES2 renderer (src/render_gl.c), about 11k lines |
| Rejected base | alexbeavs-ps1-ports/wipeout-recomp (psxrecomp). It emulates the PS1 GPU, which sees only 2D screen-space triangles, and the game culls to its native frustum. Stereo would need PGXP depth reconstruction plus MIPS reverse engineering. Decided with the user on 2026-10-02 |
| Source scope | Personal experimental source port. The preserved upstream README says its source has no license and may derive from PC source leaked in 2022. This fork adds no grant and establishes no provenance. Never commit game data |
| Game data | User-supplied PSX NTSC data plus the PC menu models, laid out as upstream's README describes, under $XDG_DATA_HOME/WipEoutVR/wipeout/ (default ~/.local/share). Saves and vr.toml go in $XDG_DATA_HOME/WipEoutVR/. The VR build resolves both at runtime and ignores PATH_ASSETS and PATH_USERDATA |
2. Triage facts (from the upstream source, 2026-10-02)
| Question | Answer | Consequence |
|---|---|---|
| Sim tick | Variable step. system_update() takes dt = min(real_delta, 0.1) * time_scale from platform_now(), then game_update() simulates and issues draws |
No display-time scheduler. In VR, dt comes from the change in predictedDisplayTime, and the sim runs once per XR frame |
| Interpolation | None; not needed with a variable step | |
| Camera | camera_t (src/wipeout/camera.c): position plus yaw/pitch/roll angles. Race modes are external chase (camera_update_race_external) and internal cockpit (camera_update_race_internal, roll = save.internal_roll). render_set_view(pos, angles) builds the view matrix |
Anchor = game camera. Head pose composes on top |
| Projection | render_setup_3d_projection_mat: fixed 73.75° vertical FOV, near 16, far 64000 (RENDER_FADEOUT_FAR) |
Replace with each eye's asymmetric FOV, keeping the same near and far |
| Renderer | Immediate mode. State calls plus render_push_tris/sprite/2d go into a 2048-tri batch that is flushed on state change. One 2048² texture atlas. One shader (SHADER_GAME_VS/FS) with a distance fade from the camera_pos uniform |
Record a per-frame command list, then replay it per eye (section 4) |
| HUD and 2D | render_set_view_2d() switches to an orthographic projection over the backbuffer. HUD, menus and text are all render_push_2d*. hud_draw_target_icon projects a world point with render_transform (mono view) |
The 2D segment goes to its own HUD texture, shown on a quad layer |
| Input | input_set_button_state(INPUT_GAMEPAD_*, value), the game's own pad layer, which SDL feeds. Actions are bound per layer (game.c defaults: thrust A, fire X, view Y, brakes L/R shoulder, steer D-pad, menu A/B/X/Start) |
OpenXR actions feed the same INPUT_GAMEPAD_* codes, so the game's bindings and prompts stay valid |
| Existing Linux port | Yes: SDL2 + GLEW, CMake and a Makefile | Same route as F-Zero X and Star Fox |
3. Frame protocol (VR build)
vr_frame_begin():xrWaitFrame,xrBeginFrame, andxrLocateViewsatpredictedDisplayTime. Poll actions and merge them into the input layer. Run the system-layer state machine (sfvr_view).system_update()computesdtfrom the change inpredictedDisplayTime, clamped to the game's 0.1 s. During standby (shouldRender = false) or focus loss, it freezes the sim (dt = 0) and pauses audio, while still polling.game_update()runs once. In VR, renderer calls append to the frame's command list and issue no GL.- Replay:
- Stereo frame (race scene, with a
render_set_viewfrom the race camera): the world segment is replayed per eye into the projection-layer swapchains, and the 2D segment is replayed once into the HUD swapchain. - Mono frame (intro, title, menus, highscores; and race while
vr.force_monois set): the whole list is replayed once with the game camera and native projection into the panel swapchain, shown on a world-locked quad.
- Stereo frame (race scene, with a
vr_frame_end():xrEndFramewith the projection layer (stereo only), plus the HUD quad or the panel quad, plus the fade quad when one is active.
Current state (phase 2, host-verified only). All five steps exist.
platform_sdl.ccallsvr_frame_begin()after pumping SDL events;system_update()takesdtfromvr_frame_dt()(the change inpredictedDisplayTime, then the game's 0.1 s clamp).render_frame_prepare()starts recording andrender_frame_end()replays;platform_end_frame()callsvr_present_frame(), which ends the XR frame with the layers the replay produced.- The renderer (
src/vr/vr_render_gl.inc, compiled insiderender_gl.c) asks a provider for the frame's views and for images to draw into.vr.cis the provider for the runtime; the host harness has a fake one. - Layers, bottom to top (
vr_compose.c): the projection layer (stereo), the HUD quad (stereo), the world-locked panel quad (mono), a black fade quad in VIEW space while a mono/stereo switch fades. - While
shouldRenderis false or the app lacks focus,dtis 0 and SDL audio is paused, and the loop keeps waiting, beginning and ending frames. A standby frame records but replays nothing.force_renderoverrides both: it draws and runs the sim, for unattended tests. Vsync is off, sincexrWaitFramepaces the loop. - Which frames are stereo:
race.ccallsrender_vr_race_view(!g.is_attract_mode)before the race camera'srender_set_view, so every race frame is stereo (including the intro fly-over and the pause menu, whose 2D goes on the HUD quad) except the attract mode's cutting cameras, and any frame whilevr.force_monois set, or while the head cannot be tracked. Everything else (intro video, title, menus, the main menu's 3D models, highscores) is mono. - A mode switch (including the first frame) starts the fade and a recentre. A recentre is also requested by
vr_take_recentre_request()(the input worker's contract; a weak stub invr.cstands in untilvr_input.cmerges), by focus gain and byXrEventDataReferenceSpaceChangePending. It is applied at the start of the next stereo frame (the mono panel is re-placed at the next mono frame). - Not built:
hud_modefar and leash (near is used, with a log line),hud_visible, a desktop mirror of the frame (the window shows nothing).
Every per-frame state change (pickup colour cycling, particles, cycle_time) happens inside game_update, so the per-eye replay cannot advance state twice.
4. Stereo renderer
-
Command list (
vr_cmd.[ch], pure data; the hooks are one fencedif (vrr.recording)per function inrender_gl.c): the commands areVIEW(pos, angles)andVIEW_2D;MODEL_MAT(flagged when it is the sky),DEPTH_WRITE,DEPTH_TEST,DEPTH_OFFSET,BLEND,CULL,SCREEN_POS;TRIS(first, count), an index range into the frame's triangle arena, with atlas UVs already applied; consecutive pushes are one range;SPRITE(pos, size, color, tex);FACE_BEGIN(id, size, clear),FACE_ENDandBIND_FACE(id)for the cockpit's instrument faces (below).
Texture creation and upload stay immediate, because they are resources rather than draws. Recording reproduces the flat path's state semantics:
VIEWresets depth test and write and the model matrix,render_set_blend_modeignores a repeat,view_matandsprite_matare kept forrender_transform, and the blend and cull state persist across frames (the list records the state a frame started in). Each replay pass sets its whole GL state explicitly, withGL_FRAMEBUFFER_SRGBoff (the game's colours are gamma-encoded already), so no pass depends on another. The arena goes to the GPU once per frame; sprites are expanded per pass, into a region of their own per pass. -
Segments: a command recorded after
VIEW_2Dis in the HUD segment; one recorded betweenFACE_BEGINandFACE_ENDis in the face segment. Each pass runs every state command and draws only its segment: eyes draw the 3D segment, the HUD passes the 2D one, a mono pass both, and the face pass (once a frame, before the eyes, only when a face was recorded) the face segment, each face into its own RGBA8 texture. -
Cockpit (
src/vr/vr_cockpit.{h,c},vr_cockpit_core.{h,c}; design and status indocs/COCKPIT_MOCKUP.md): in a stereo race frame with the internal camera the player's ship is drawn as cockpit H (the hull with the cabin cut out, authored parts, the glass) and the eyes anchor at the fitted eye throughrender_vr_cockpit_anchor()instead of the game camera. The floating HUD is replaced by three instrument faces drawn with the game's own 2D calls into small textures once a frame (the game's 2D code runs once; both eyes sample the textures). The chase view, the attract cutting cameras, the mono panel andcockpit = offare as before. -
Coordinate conventions (derived from
render_set_viewandmat4_set_*insrc/types.c, restated and tested invr_math.c):- OpenXR: right-handed, +X right, +Y up, -Z forward, metres.
- The game's world: +X right, +Y down, +Z forward at yaw 0, game units. Right-handed (the usual camera triple).
render_set_view(pos, angles)buildsR * T(-pos)withR = mat4_set_roll_pitch_yaw(pitch, -yaw + M_PI, roll + M_PI). The twoM_PIoffsets are what turn the Y-down world into GL view space: at angles 0,R = diag(1, -1, -1).- GL view space (+X right, +Y up, -Z forward) has the same axes as OpenXR, so an OpenXR pose is already in the view space of the game camera. No axis swap is needed.
- The game's
sprite_matisR^T * diag(-1, -1, 1)at roll 0.
-
Eye view: with
Athe anchor's view rotation (fromvr_anchor_angles),Rrcthe rotation by minus the recentre yaw about +Y andMthe eye's orientation, the eye's view rotation is(Rrc * M)^T * A. Its position is the game camera's plusA^T * world_scale * (head_translation * Rrc * (head - origin) + Rrc * (eye - head)): the head centre (midway between the eyes) is scaled byhead_translation, the eye's offset from it never is, and the IPD comes only from the runtime's eye poses. With an identity head at the recentre origin, the centre eye's view equals the game's ownview_matexactly (tested). -
Eye projection: the runtime's asymmetric fov in the game's GL clip convention (
zin -1..1), near 16, far 64000 (NEAR_PLANE,FAR_PLANE). Thecamera_posuniform of the distance fade is the eye's position. -
Anchor: the game camera's position, yaw and pitch, with roll 0, so pitch follows the track. Roll follows the camera (
save.internal_roll) only whenvr.follow_vehicle_rotationis on.vr.horizon_lockalso drops pitch. The eye images are submitted with the poses and fovs they were rendered with. -
Recentre: records the head's yaw and position (
vr_recentre_t); the head then sits at the game camera, facing where the camera faces, so the yaw is transferred into the VR offset. It is triggered by the input layer's request (L View held 1 s, the input worker),XrEventDataReferenceSpaceChangePending, focus gain, and every mono/stereo switch, and applied at the start of a stereo frame.diag_yawturns the head after the recentre, so it stays visible. -
HUD (
hud_mode near): a quad layerhud_distancemetres (default 2.0) along the head's forward, following its yaw and pitch and never its roll (vr_hud_pose), 40 degrees wide athud_scale100% (the width scales with the percentage), 16:9, in a 1920x1080 texture (the 2D layout size, which is alsorender_size()in VR;ui_scaleis 3). The 2D segment is replayed twice, over black and over white, and a resolve pass solves for the alpha (a = 1 - mean(over_white - over_black), colour = over_black / a, straight alpha,UNPREMULTIPLIEDlayer flag). The game's own framebuffer alpha is never read. -
Target reticle:
render_transformin a stereo frame projects through the head-centre camera with its roll removed onto the HUD quad's frustum, so the reticle sits over its target as seen from the head. Parallax between the two eyes at the quad's 2 m is not corrected. -
Mono panel: the 1920x1080 texture, the game's camera and native projection, one pass, on a quad
panel_widthmetres wide,panel_distanceahead of the head (levelled, heading only), world-locked, re-placed on recentre requests. -
Fade: a 4x4 black swapchain image at the fade's alpha on a quad in VIEW space 0.35 m ahead, 2.6 m wide, 0.25 s at alpha 1 then 0.35 s linear to 0, on every mono/stereo switch and the first frame.
-
Diagnostics (section 7):
diag_yaw,dump_frame,timing,force_render.
5. Draw inventory (camera-relative and screen-space draws)
| File:function | What it draws | Flat rule | VR rule | Verified by |
|---|---|---|---|---|
scene.c:scene_draw (sky) |
Sky object translated to camera->position + sky_offset, depth write off |
Follows the camera | render_vr_sky(true) around the draw tags its MODEL_MAT; replay re-translates it from the game camera to each eye |
Host harness "sky at infinity": the +x/+z sky edge stays within 2 px when the head moves 1 m (without the re-translate it moves 9 px). scene.c itself has not run (no game data) |
scene.c:scene_draw (objects) |
Cull: dot(cam_pos - origin, cam_fwd) < radius and distance < 64000 |
Forward half-space cull from the game camera | render_vr_wide_cull() in a stereo frame skips the half-space test: distance only |
Host harness at head yaw 180: the marker behind the camera is drawn and centred in both eyes (it fails with the hook forced off). The harness mirrors the cull logic through the same hook; scene.c has not run |
track.c:track_draw |
Sections, culled by the camera-forward plane | Forward half-space cull | Same hook: distance only, FAR + radius |
Hook shared with the row above; the harness ground strip is not culled. track.c has not run; the diff is reviewable and flat-unchanged |
ship.c:ships_draw (the pilot's ship in the internal view) |
Skipped in the flat game: the camera is inside the ship | Not drawn | In a stereo internal race frame vr_cockpit_draw() draws the cockpit instead (hull with the cabin cut out, parts double-sided, face quads, glass last), in a model matrix per bank mode. The head sits at the fitted eye, 42 units above the game camera, which keeps its place for the sim and the sound |
Host harness: draws only in stereo internal view (chase, mono, attract, rescue, unmatched hull, cockpit off and force_mono each leave it out); the eyes at the seat in all three bank modes; a world marker lands where the recorded cameras say. Unit tests for the maths against src/types.c. Headset-unverified: scale, comfort, the bank modes |
race.c 2D section, hud.c:hud_draw |
HUD panel on the HUD quad | The 2D segment on the HUD quad | In the cockpit vr_cockpit_hud_draw() replaces hud_draw(): the same ui_draw_* calls at texel positions into the three face textures (the face pass). WRONG WAY and the target reticle stay on the HUD quad (hud_draw_overlay) |
Harness: the faces are replayed once per game frame (the status slot's shield read happens once while two eyes and the HUD replay), a half-alpha element is single-blended, the shield bar and pods appear in the eye images; real fonts and speedo drawn from the user's data in the data-gated run. Text legibility at the pods is unjudged |
render_gl.c:render_push_sprite |
Billboards expanded with sprite_mat from the game camera angles |
Faces the game camera | Recorded as SPRITE; expanded per pass facing that eye with yaw and pitch only (vr_sprite_upright), so billboards never roll with the head (standard.md section 4, 2026-10-04) |
Host harness "sprite facing": head turned 30 degrees, both eyes, aspect 0.979 (a game-camera billboard would be 0.87). The mono replay of the same sprite equals the flat renderer pixel for pixel. vr_view pins the matrix against sprite_mat, and checks that a 57 degree head roll leaves it unchanged |
render_gl.c vertex shader fade |
Alpha fade by distance from camera_pos |
Game camera | camera_pos is the eye's position, set at each VIEW |
Host harness: a cube 55400 units away, head leaning 3 m forward, blue channel within 0.2 levels of the analytic smoothstep for both eyes (it fails with the game camera's position) |
race.c render_set_screen_position(g.camera.shake) |
Screen-space shake offset (screen uniform) |
Clip-space offset | SCREEN_POS is ignored in eye and HUD passes (kept in the mono panel, where the main menu uses it to place models). The camera_shake key does nothing yet. The optional impact haptic is the input worker's |
Host harness: every stereo frame sets a (0.3, 0.3) shake and the markers still land on their analytic pixels; the mono frame with the same shake equals the flat renderer |
ship.c / particle.c depth offset −32 |
Shadows and particles | Polygon offset | Unchanged, per eye (DEPTH_OFFSET replays as glPolygonOffset) |
The harness sprite is drawn with it, additive and without depth write, equal to flat in mono. Shadows have not drawn (no data) |
hud.c:hud_draw_target_icon |
Target reticle from render_transform (mono view and projection) |
Screen projection | In a stereo frame render_transform projects through the head-centre camera (roll removed) onto the HUD quad's frustum |
Host harness: straight ahead lands at NDC (0, 0), 10 degrees right at 0.4845 (tan 10 / tan 20), behind the head z >= 1; vr_view pins the HUD camera. Headset-unverified: the reticle is a 2 m overlay while its target is far, so the eyes see parallax between them |
hud.c, ui.c, menu.c 2D |
All 2D text and quads, after render_set_view_2d |
Ortho over the backbuffer | HUD swapchain on a quad. Alpha derived from renders over black and over white (lesson 6) | Host harness: authored alpha 128, 255 and 64 come back as 128, 255 and 64 (colour within 10), 88% of the HUD transparent where nothing was drawn (it fails with alpha forced to 1). hud.c and ui.c run in the data-gated cockpit harness (section 9); menu.c has not run; text legibility at 40 degrees is unjudged |
main_menu.c 3D ship and logo models |
render_set_view at the origin, plus a screen offset |
Mono 3D | Mono frame on the world-locked panel (no render_vr_race_view) |
Host harness: a mono frame with render_set_screen_position, depth offsets and additive sprites is 0 of 518400 pixels different from the flat renderer. main_menu.c has not run |
intro.c MPEG video |
2D texture | Fullscreen | Mono panel (cinema quad later). render_texture_replace_pixels stays immediate |
The mono path as above. The video has not played |
render_gl.c post effects (CRT) and RENDER_RES_240P/480P |
Backbuffer post-process | Fullscreen | Ignored while the renderer is attached; render_size() is the 2D layout size. The eyes are the runtime's recommended size times resolution_scale (phase 1) |
Host harness: with CRT and 240p requested, render_size() stays 960x540 and the mono replay still equals the flat renderer without them |
6. Controls (src/vr/vr_input.{h,c})
OpenXR actions feed the game's own pad layer, so its bindings, prompts and menus stay valid. One action set (wipeout) holds 20 actions: 14 booleans (A, B, X, Y, the two bumpers or grips, Menu, View, the two stick clicks, the D-pad), 2 floats (the triggers), 2 vec2 (the sticks) and 2 vibration outputs. Setup order: xrCreateActionSet, every xrCreateAction, xrSuggestInteractionProfileBindings (Frame, Index, Touch), xrAttachSessionActionSets, all right after xrCreateSession (vr_input_create). The Frame profile is suggested only when XR_VALVE_frame_controller_interaction is enabled (vr_xr_t.ext_frame_controller, which is true only when the runtime offered it). A runtime that rejects a profile is logged and skipped.
Merge. Each frame, after xrBeginFrame (vr_frame_begin in vr.c), vr_input_update calls xrSyncActions and reads every action. It then calls input_set_button_state(INPUT_GAMEPAD_*, value) only for codes whose value changed since the last frame. Sticks and triggers become the game's split-axis codes the way platform_sdl.c does it: a trigger is its own code (0..1), and each stick axis is a pair (LEFT/RIGHT, UP/DOWN) with at most one non-zero; stick up is UP. With no focus, no running session or a sync that is not XR_SUCCESS (XR_SESSION_NOT_FOCUSED), every non-zero code is written back to 0, the L View press is cancelled without an event, queued haptics are dropped and playing ones are stopped.
Game codes, by profile. The same game code on every profile. The columns are the OpenXR component paths that were suggested (all under /user/hand/).
| Game code | Game action | Frame (/interaction_profiles/valve/frame_controller_valve) |
Index (/interaction_profiles/valve/index_controller) |
Touch (/interaction_profiles/oculus/touch_controller) |
|---|---|---|---|---|
L_STICK_* (LEFT/RIGHT/UP/DOWN) |
Steer and pitch (VR alias); menu navigation (system layer) | left/input/thumbstick |
left/input/thumbstick |
left/input/thumbstick |
R_STICK_* |
Unbound by default; free for the controls menu | right/input/thumbstick |
right/input/thumbstick |
right/input/thumbstick |
DPAD_* |
Steer, pitch (game default); menus | left/input/dpad_{up,down,left,right}/click |
none | none |
A |
Thrust (game default), menu select | right/input/a/click |
right/input/a/click |
right/input/a/click |
B |
Menu back | right/input/b/click |
none | left/input/x/click |
X |
Fire (game default), menu back | right/input/x/click |
right/input/b/click |
right/input/b/click |
Y |
Change view | right/input/y/click |
right/input/thumbstick/click |
right/input/thumbstick/click |
L_SHOULDER / R_SHOULDER |
Air brakes | left/input/bumper/click / right/input/bumper/click |
left/input/squeeze/value / right/input/squeeze/value |
left/input/squeeze/value / right/input/squeeze/value |
L_TRIGGER |
Unbound by default | left/input/trigger/value |
left/input/trigger/value |
left/input/trigger/value |
R_TRIGGER |
Thrust (VR alias) | right/input/trigger/value |
right/input/trigger/value |
right/input/trigger/value |
START |
Pause / start (system layer: R Menu) | right/input/menu/click |
left/input/b/click |
left/input/menu/click |
SELECT |
Menu back (system layer: L View short press); never bound in the game's own layer | left/input/view/click |
left/input/a/click |
left/input/y/click |
L_STICK_PRESS |
Unbound by default | left/input/thumbstick/click |
left/input/thumbstick/click |
left/input/thumbstick/click |
R_STICK_PRESS |
Unbound by default | right/input/thumbstick/click |
none (the right stick click is Y) |
none (the right stick click is Y) |
| Haptics | system, impact, ui_click |
left/output/haptic, right/output/haptic |
same | same |
Sources of the component paths (all unverified on the device; the Frame was offline):
- Frame:
/Users/mads/code/F-Zero X/port/vr/gdx_openxr.cpp(theframe_controller_valvesuggestion, about lines 663 to 682) and/Users/mads/code/HaloCE VR/port/android/host/host_xr.c(theframe[]table, about lines 317 to 351). Both cite the Steamworks "Steam Frame Input" page. They agree on every path used here but one: F-Zero X binds the triggers astrigger/click, Halo astrigger/value. This port usestrigger/value, because thrust is analog and the actions are floats. Stick clicks and the right thumbstick come from Halo's table. The skill'splatform.mdgives only the layout (D-pad left, ABXY right, sticks, triggers, grips, bumpers, Menu, View). - Index and Touch paths: F-Zero X's suggestions (
gdx_openxr.cppabout lines 684 to 709: thumbsticks,a/click,b/click,squeeze/value,trigger/value, the lefta/bory/menustand-ins for View and Menu) plus Halo's Touch table (left/input/x/click,y/click,menu/click) and Index table (thumbstick/click). The assignment of those inputs to game codes is this port's own: neither has bumpers, a D-pad, View or a right Menu button, so the grips are the air brakes, a left face button stands in for View, and another for Menu. - On the Frame the physical letters are the game's letters (the game prints
A,X,Y,LSHLDRin its controls menu). Index and Touch cannot match them (rightBisX). The final map still needs the user's approval (phase 4).
Default aliases (VR build, once, at the end of game_init). The game's own bindings are kept as they are. vr_input_apply_default_bindings(save.buttons) adds, in the user input layer:
L_STICK_LEFT/RIGHT/UP/DOWNtoA_LEFT/A_RIGHT/A_UP/A_DOWN;R_TRIGGERtoA_THRUST.
An alias is added only when nothing is bound to its button in the user layer and the action's joystick slot in save.buttons still holds the game's default (D-pad direction, or A). That is, it is skipped when the user has bound the stick direction or trigger, or has rebound or cleared that action's joystick slot. The aliases sit outside save.buttons, which has one joystick slot per action and is written to the save file. So the D-pad and A stay in the slot, a flat build reading the same save does not see the aliases, and the controls menu (which shows the slot) shows what the user set; it does not list the aliases. The system layer is untouched, because L_STICK_* is already bound there to the menu actions. A binding made later from the controls menu replaces the alias for that button (input_bind keeps one action per button per layer). SELECT is bound to A_MENU_BACK in the system layer unless something is bound there already (input_layer_bound_to_action, added behind WIPEOUT_VR at the end of input.c). Upstream's controls menu does not unbind the old button of a rebound action until restart; that is unchanged.
System layer (vr_pad_update, L View through sfvr_view at the frame's predicted display time):
| Input | Result |
|---|---|
| R Menu | INPUT_GAMEPAD_START |
| L View, short press | INPUT_GAMEPAD_SELECT for one frame (then A_MENU_BACK in menus) |
| L View held 1 s, and again at 3 s | vr_request_recentre() and the system haptic on both hands. The renderer consumes vr_take_recentre_request(); the input module recentres nothing. There is no standing height in a seated racer, so 3 s does the same as 1 s |
| System button | Never bound |
A View press that was already down when focus returned is ignored until it is released and pressed again.
Haptics (the sfvr_haptics queue, flushed once a frame in vr_input_update through xrApplyHapticFeedback, scaled by vr.haptics (default 0.6), XR_FREQUENCY_UNSPECIFIED, coalesced per hand to the strongest amplitude and longest pulse):
| Event | Trigger | Pulse (before vr.haptics) |
Hands |
|---|---|---|---|
system |
L View hold, 1 s and 3 s | 0.6 / 80 ms | both |
impact |
camera_set_shake (camera.c, one fenced call; ship hits and weapon hits). Not gated by vr.camera_shake |
0.35 / 30 ms for a 0 s shake, up to 1.0 / 80 ms at CAMERA_SHAKE_LONG (2/3 s), linear in the duration. CAMERA_SHAKE_SHORT gives 0.415 / 37 ms |
both |
ui_click |
sfx_play(SFX_MENU_SELECT) (sfx.c, one fenced call): every menu select, toggle, back and the title screen's start |
0.5 / 30 ms | both. There is no pointing hand: select is on the right and navigation on the left |
ui_hover |
not hooked (SFX_MENU_MOVE would be the place) |
Handedness (dominant_hand) is not applied: thrust is always the right trigger. The settings page, which the standard calls for, is not built.
7. Settings and diagnostics (vr.* keys; env WIPEOUT_VR_<KEY>)
The settings file is $XDG_DATA_HOME/WipEoutVR/vr.toml (~/.local/share/WipEoutVR/vr.toml when XDG_DATA_HOME is unset or relative). It has a top-level version (the port's schema, 2; sfvr's registry is schema 1) and a [vr] table. src/vr/vr_settings.c reads it with the sfvr key registry (third_party/sfvr, which is a registry only and does no file IO) plus the port keys below:
- A missing key gets its default, an invalid value (not a number, unknown enum or bool text) gets the default, an out-of-range value is clamped. Unknown keys, other tables, comments and layout are kept as they are. The file is rewritten, atomically (temp file,
fsync,rename), only when something was added or repaired. - Migration. A file older than
VR_SETTINGS_VERSION(a missingversioncounts as older) has a storedrefresh_rate = 90, the old default, moved to the new default (120); a value the user changed (72, 144) is kept. The Frame's file stores every key, so new defaults alone would change nothing. Schema 2 also addseye_size, which a missing key gets by default. The table ismigrations[]invr_settings.c, tested intest_vr_settings. WIPEOUT_VR_<KEY>(for exampleWIPEOUT_VR_RESOLUTION_SCALE=1.2) overrides a key for the run. It is clamped the same way and never written back.
Port keys (not in the sfvr registry):
| Key | Default | Meaning |
|---|---|---|
eye_size |
2160 (0 to 4096) | The eye image's side in pixels before resolution_scale, whatever size the runtime recommends (1728 standalone, 2160 with SteamVR's per-app 2160 video setting): the Frame's panel is 2160 square. 0 gives the runtime's recommended size times resolution_scale. The images are created at the final size and rendered into directly; nothing is rendered larger and blitted down |
world_scale |
88.0 (16 to 1024) | Game units per metre. Decided with the user on 2026-10-04: the ships come out 9.7 m long and the track 30 to 40 m wide, and cockpit H is fitted at it. It applies to the chase view too. Confirm at first wear |
cockpit |
true | The internal view becomes cockpit H in stereo race frames. Off: the flat internal view and the floating HUD. Page-bound |
cockpit_bank |
level (level, follow, cockpit) |
How the cockpit follows the ship's roll: level in banks, view and cockpit roll together, or the cockpit rolls around a level head. Page-bound |
cockpit_eye_forward_cm, cockpit_eye_up_cm |
0 (-30 to 30) | Seat offset from the fitted eye in physical centimetres (cm / 100 * world_scale game units). Page-bound |
cockpit_glass |
true | The canopy glass tint. Config only |
force_mono |
false | Keep race frames on the mono panel, with no stereo |
diag_attract_stereo |
false | Diagnostic: render the attract-mode race in stereo, so unattended eye dumps show real game stereo |
diag_attract_cockpit |
false | Diagnostic: the attract mode always uses its internal camera, flagged as the internal view (camera.c, fenced), so an unattended run with diag_attract_stereo shows the cockpit. In the flat attract mode the internal camera has no internal-view flag |
panel_distance |
2.0 m (0.8 to 20) | The flat panel's distance |
panel_width |
2.4 m (0.5 to 10) | The flat panel's width; its height follows the window's aspect |
The cockpit keys are read from the live settings at every frame (vr_render_config_t.live_settings), so a settings page that calls vr_settings_set() takes effect on the next frame with nothing rebuilt; the page is not built (it will be designed separately). "Page-bound" marks the keys that are to go on it. The doc's other keys are sfvr standard keys with sfvr's defaults, except refresh_rate: follow_vehicle_rotation (off), horizon_lock (off), hud_mode (near), hud_distance (2.0 m), hud_scale (100), resolution_scale (1.0; a multiplier on eye_size), refresh_rate (120, the rate requested; sfvr says 90, and the port overrides it, see below), camera_shake (off), and the rest of the standard set. The settings page (phase 4) is not built.
Diagnostics: force_render, diag_yaw, dump_frame, probe_seconds, timing, timing_gpu are registry keys, all implemented. Log prefixes are [vr] for state changes and [vr-perf] every 10 s.
force_render: submit layers and run the sim in standby or without focus, with a synthetic head at 1.6 m (vr_xr.c), so unattended runs over SSH draw and play.diag_yaw(degrees, positive turns left): turns the head about its centre after the recentre, so a synthetic head yaw is visible. The eyes are submitted with the turned poses.dump_frame=N: the N-th replayed frame (counting from 1) writesvr-eye0.bmp,vr-eye1.bmp(24-bit) andvr-hud.bmp(32-bit, straight alpha) for a stereo frame,vr-panel.bmpfor a mono one, andvr-state.txt(mode, recentre, head and eye poses, game-space eye positions, HUD pose, fade, counts, timings) into<data>/vr-dumps/. The pixels are copied into a PBO behind a fence and written by a later frame, so the frame never waits; nothing reads back in a normal frame.timing: a[vr-perf]line every 10 s throughsfvr_perf:fps,missed(display time advanced by more than 1.5 periods),cpuand the stagesrecord,replay(both eyes) andhud,eye=left/right,gpu=n/a. All are CPU submission times.recordis measured with a clock read per recorded call, so it only costs anything undertiming.timing_gpu: GL timestamp queries (glQueryCounter,GL_TIMESTAMP) around the face pass, each eye, the HUD passes (with the alpha resolve) and the mono panel, in a ring of 4 frames. A frame's queries are read two or three frames later, only once the newest one reportsGL_QUERY_RESULT_AVAILABLE, so the CPU never waits on the GPU (lesson 10); a frame whose queries are still not ready when its slot is reused is dropped and logged. It fillsgpu=(the mean over frames that had a sample of the sum of the passes) and adds a second line every 10 s,[vr-perf] gpu eye=L/Rms face=F hud=H mono=M max=Xms n=N(eye, face and hud average the stereo frames, mono the mono ones, max is the worst frame's total). With the setting off nothing is created: no queries, no flush, one pointer test per pass. With it on, the replay ends with aglFlush()(not a wait) so the queries are handed to the driver. Needs GL 3.3 timestamps; the Apple legacy-header build says it is off.
Display (standard section 9, decided 2026-10-05). Target 120 Hz at a native 2160 eye image.
- Defaults and who owns them. sfvr's registry (vendored, not edited) says
refresh_rate90 andresolution_scale1.0. The port overridesrefresh_rateto 120 invr_settings.c(std_overrides[], whichvr_settings_key()returns instead of the registry's entry) and adds the port keyeye_size(2160).resolution_scalekeeps sfvr's 1.0 and multiplieseye_size. An early plan was a fixedresolution_scale1.25 (1.25 x 1728), but SteamVR's per-app video setting makes the runtime recommend 2160 itself, and 1.25 would then have asked for 2700;eye_sizegives 2160 whichever size is recommended. - The app cannot change the rate on the Frame. SteamVR offers only the title's per-app preferred rate, so there is no fall-back ladder (it could not take effect; standard section 9). The user sets WipEoutVR to the wanted rate once, in SteamVR's per-app settings in the headset. Editing
steamvr.vrsettingswhile SteamVR runs does nothing. - Refresh request (
vr_xr.c,vr_refresh.[ch]). At session start the offered rates are enumerated and logged ([vr] refresh rate: the runtime offers ...), andxrRequestDisplayRefreshRateFBis called once, for the offered rate closest torefresh_rate(the lower one on a tie). A refused request is logged once and never retried. The runtime's rate-changed event is logged. The rate in effect is taken frompredictedDisplayPeriod, not the offered list (the extension can list less than the compositor runs). - Not-held recommendation (
vr_refresh.[ch], pure,test_vr_refresh). While worn (session focused,shouldRendertrue,force_renderoff), frames are counted in 10 s windows of display time. Two consecutive windows below 90% of the display rate log one line, once per session, and change nothing:[vr] refresh rate: 120 Hz is not held (A and B fps ...). The app cannot change it: set this title to N Hz in SteamVR's per-app settings, where N is the highest of 144, 120, 90, 72 that the measured fps holds (at the lowest rate, the line says the frame cost needs cutting instead). A window that was not worn throughout, a change of display rate, or a stall of more than 5 s between frames restarts the count; a window at or above 90% resets it. - Recommended per-app setting. WipEoutVR at 120 Hz and 2160 (the user has set this). The measured table below says whether the frame holds it.
- Logs to read.
session created: ... eye WxH (recommended, max), eye_size x resolution_scale, refresh_rate, the offered rates, and undertiminga[vr] display:line with the rate in effect before the first[vr-perf]line.
Measured on the Frame (2026-10-05, build 942974d, unattended, force_render, attract race in stereo, 110 s per run, steady 10 s windows after the title). Launched as the Steam title (steam://rungameid), with the per-app setting the user made (steam.app.3759213738: preferredRefreshRate 120, resolutionOverride 2160); force_render, timing, timing_gpu and diag_attract_* set in vr.toml, then put back. Runtime: SteamVR OpenXR 2.17.10, offered rates 120, recommended eye 2160x2160, max 8192. xrRequestDisplayRefreshRateFB(120) is refused (XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB) even though 120 is the offered rate, so the rate in effect is the per-app one: 120 Hz by predictedDisplayPeriod. Budget at 120 Hz is 8.33 ms.
| Run | Eye (per eye) | fps | Missed per 10 s | CPU ms: cpu / record / replay (both eyes) / hud | GPU ms: eye L / eye R / faces / HUD | GPU total mean (worst frame) |
|---|---|---|---|---|---|---|
Cockpit, eye_size 1728 |
1728x1728 | 120.0 | 0 | 0.70 / 0.64 / 0.05 / 0.02 | 0.79 / 0.79 / 0.07 / 0.28 | 1.92 (2.8) |
Cockpit, default eye_size 2160 |
2160x2160 | 120.0 | 0 | 0.89 / 0.70 / 0.04 / 0.14 | 1.18 / 1.23 / 0.07 / 0.29 | 2.77 (3.2) |
| Chase view, 2160 (cockpit diag off) | 2160x2160 | 120.0 | 0 | 0.74 / 0.58 / 0.05 / 0.10 | 0.96 / 0.93 / 0 / 0.26 | 2.16 (3.2) |
Cockpit, eye_size 2700 (headroom probe) |
2700x2700 | 120.0 | 0 | 0.82 / 0.68 / 0.04 / 0.10 | 1.80 / 1.96 / 0.07 / 0.31 | 4.14 (6.5) |
- All steady windows held 120.0 fps with 0 missed frames; the only missed frames (2 to 5 per run) are in the first window, the intro and title. No "not held" line was logged.
- Eye GPU time scales with the pixels: 0.79 ms at 1728, 1.2 ms at 2160, 1.9 ms at 2700. The numbers are for the attract race (one ship, an empty track), so a real race with more ships and effects will cost more; the 2160 cockpit frame has about 5.5 ms of headroom against 8.33 ms. Timestamps on a tiling GPU through Zink may under-report;
fps=120.0 missed=0is the proof, the per-pass numbers say where the time goes. - A binary started over SSH (not a Steam launch; the per-app rate does not apply) got a different runtime: recommended 1728, the list offering only 120 while
predictedDisplayPeriodsaid 72 Hz (72 fps, 0 missed, GPU 2.5 ms at 2160x2160), with the same refused request. This is why the rate in effect comes from the period and the real measurement is a Steam launch. During startup the period reads 18, 30, 36 and 60 Hz for a moment before it settles; each reading is logged and restarts the check. - The attract race did render in every run: the first 10 s window is the intro and title (mono panel, 0.7 ms GPU), every later window is the stereo race with the cockpit faces. The earlier unattended run that sat on the title for four minutes did not recur (its cause was not established; it did not recur here).
Probe. vr.probe_seconds = N (or WIPEOUT_VR_PROBE_SECONDS=N) makes the VR build clear the left eye red and the right eye blue for N seconds after the session is running, before system_init() loads anything. It logs the swapchain format, eye size and graphics binding with the [vr] prefix. It waits up to 20 s for the session to start, and says so if standby (shouldRender false) is all it gets.
8. Status
Levels: worn (the user played it in the headset), device (unattended run on the Frame), host (Mac or Linux render or test), not started.
| Item | Phase | Level | Notes |
|---|---|---|---|
| Base chosen, fork created (private) | 0 | done | astromaddie/wipeout-vr |
WIPEOUT_VR flag; flat build unchanged |
0 | host | Proof in section 9: Mac clang and Linux arm64 gcc-12 |
| OpenXR host (instance, session, spaces, swapchains, frame loop, extensions) | 0 | host | src/vr/vr_xr.c. Fake-runtime tests only; never run against SteamVR |
| GL binding from SDL's x11 window | 0 | device | 2026-10-02: Zink on Turnip, GL 4.6 core, Xlib binding, xrCreateSession OK; the GLSL 330 shaders compile on the Frame |
| OpenXR probe (colour per eye) | 0 | device | 2026-10-02 over SSH with force_render: sRGB 0x8c43 swapchains at 1728², 563 of 564 frames drawn in 8 s (72 Hz). Seeing red/blue in the headset is still owed (first wear). The runtime refuses xrRequestDisplayRefreshRateFB (UNSUPPORTED), so the rate is whatever the Frame's display settings say |
Settings (vr.toml, env overrides) |
1 | host | Unit-tested; diag_yaw, dump_frame and timing now do something |
Display defaults, schema 2 migration (refresh_rate 120, eye_size 2160) |
3 | host | Port-side override of sfvr's refresh_rate default and the port key eye_size; the Frame's stored refresh_rate = 90 migrates to 120. Unit-tested, including load and save of the Frame's file. Device: see the measured table below |
| Refresh request and not-held recommendation | 3 | host | vr_refresh.[ch] (pure) plus the fake-runtime tests in test_vr_xr: offered rates logged, the closest offered rate requested once, two 10 s windows below 90% of the period's rate while worn log one per-app recommendation (once per session), refusals are quiet. No ladder: the app cannot change the rate on the Frame |
timing_gpu (GL timestamp queries per pass) |
3 | host | Ring of 4, read 2 to 3 frames late, no sync readback; harness on llvmpipe: first sample at frame 3, one a frame, none lost. Device: see the measured table below |
| Linux ARM64 build (Docker), ABI check, tests | 1 | host | Section 9 |
| Flat game on a world-locked panel | 1 | host | Now the mono path: the panel is rendered into directly (no back-buffer copy). Equal to the flat renderer in the harness; never drawn a real frame, no game data and no headset |
Device scripts (LAUNCH-STEAM-FRAME.sh, deploy.sh) |
1 | device | deploy.sh copies, registers the devkit title and pushes --data. A Steam launch has not happened yet |
| Devkit title, data dir on the Frame | 1 | device | Title WipEoutVR registered. Data from the user's NTSC-U disc (SCUS-94301) via tools/data/prepare_psx_data.sh, in ~/.local/share/WipEoutVR/wipeout/. The PC-only leeg and msdos menu models are absent; stand-ins from the disc are used |
| Command list and per-eye replay | 2 | host | Recorded and replayed in render_gl.c (vr_render_gl.inc). Host harness: 324 pixel and state checks pass under Xvfb + llvmpipe; the mono replay equals the flat renderer. Never run against the Frame's Zink |
| HUD quad, mono panel, fades | 2 | host | HUD alpha solve, quad layer, panel and fade quads built (vr_compose, unit-tested) and rendered in the harness. Never submitted to a runtime; fade and HUD placement unworn |
| Draw inventory rows | 2 | host | All rows implemented and verified as section 5 says. The game's own scene.c, track.c, hud.c, main_menu.c have not run: no game data |
| Actions, input merge, aliases, system layer (View, Menu, recentre request) | 2 | host | src/vr/vr_input.c. Unit-tested against a fake runtime and the game's real input layer; never run against SteamVR, never worn. Binding paths are copied from F-Zero X and Halo, unverified (section 6) |
Haptics (system, impact, ui_click) |
2 | host | Same tests. The buzz has never been felt. ui_hover is not hooked |
| Recentre itself | 2 | host | View hold 1 s calls vr_request_recentre(); the renderer takes it at the next frame boundary (vr_stage tests). Never worn |
| Eye dumps at 0, 90 and 180° yaw | 2 | host + device | Host: build/vr-harness/yaw-{000,090-right,180}/, synthetic scene. Device (2026-10-02, force_render + diag_attract_stereo): the title on the mono panel, and stereo eyes of the real attract race at yaw 0 with correct disparity; 7538 tris, about 1 ms per eye, 72 fps with 0 to 1 missed frames per 10 s. Yaw 90 and 180 on the device are not dumped yet |
| Cockpit H (hull cut, trim, parts, faces, bank modes, settings keys) | 3 | host | src/vr/vr_cockpit*.{c,h}. Unit tests, the harness on synthetic hulls, and the data-gated harness on the 8 real ships with two rendered against the approved mockup (98% overlap, mean difference 10 levels). On the Frame (2026-10-04): the cockpits of all 8 ships build at game start from the real data (8 of 8) and the title runs at 72 fps; no cockpit frame has been dumped there yet (the unattended run did not get past the intro and title in the time the lock allowed). Never worn |
| Settings page (in the headset) | 3 | not started | Designed separately. The cockpit keys are registered and live so it can drive them |
| First wear | 3 | worn, accepted | 2026-10-05, build 739a8d7 (cockpit H, world_scale 88). User verdict: "overall, I would say plays perfect". The verdict covers playing the game; which features were exercised (cockpit, bank modes, recentre, haptics) was not itemised, so those rows keep their own levels until a wear note names them |
9. Build, test and deploy
Build. cmake -S . -B build -DWIPEOUT_VR=ON (Linux needs SDL2 with x11, GLEW, the X11 and GLX headers). The VR build:
- fetches OpenXR-SDK release-1.1.63 (commit
f2448a8797c85814aa892efc1ab8707900fbcc78) at configure time, checks the archive's SHA-256 (5b8dac06…a88b6f), and links its loader statically; the executable also links libstdc++ statically, so the only shared libraries to ship are SDL2 and GLEW (RPATH$ORIGIN/lib); - builds
third_party/sfvras a static library (copied unmodified, see itsVENDORED.md) andsrc/vr/aswipeout_vr_core(everything except SDL and GL), then addsvr.c,vr_gl.candvr_xr_loader.cto the game; - on Linux adds
-fsigned-char(ARM64'scharis unsigned).
On macOS the VR build configures, compiles, links and passes its unit tests, but vr_init() fails there (no GLX), so it runs flat.
Unit tests (CTest). The six sfvr tests plus its C++ header check, and vr_xr (a fake runtime behind the vr_xr_api_t function table: extension negotiation, session creation, the state machine from IDLE to FOCUSED, standby keeping the loop alive, stop and restart, exit, reference-space change, dt from display time, refresh-rate choice, failures, destroy order), vr_settings, vr_math (panel placement), vr_clock and vr_paths, and the phase-2 ones:
-
vr_input: the pad mapping with write-on-change and release on focus loss, thesfvr_viewtiming, the alias rules against the game's realsrc/input.c, and the haptic table. -
vr_input_xr: a fake runtime checks setup order, the Frame profile only when the extension is enabled, bindings, per-frame sync and release, and the haptic calls. -
vr_view: the eye and HUD maths against the game's own matrices (src/types.cis linked as the oracle). A centred identity head equalsrender_set_view's matrix (default, withfollow_vehicle_rotation, withhorizon_lock); head yaw to the right looks along the game camera's right; each eye is half the IPD along the camera's right, scaled byworld_scale;head_translationscales the head and not the eyes; recentre absorbs yaw; asymmetric fov hits the clip planes; the disparity sign; the HUD pose follows yaw and pitch and never roll;diag_yaw. -
vr_stage: the command list (order, coalescing, growth), the fade curve, mode switches, recentre applied once, the panel placed then held, and the[vr-perf]line. -
vr_cockpit: cockpit H's table, hull match, cut, trim rule, parts, eye and the three bank modes against the game's own matrices;vr_cockpit_geometry: the committed table is what the committed JSON generates. -
vr_compose: the layers a frame submits (projection with the renderer's poses, HUD flags, mono panel, fade in VIEW space, order, absent layers).
Host render harness (tests/vr/vr_render_harness.c, CTest vr_render_harness, Linux only). It links the real renderer (render_gl.c with WIPEOUT_VR, the command list, the replay, the HUD alpha solve, the dumps) and drives it with a synthetic scene, a hidden SDL window, and a fake view provider standing in for the runtime. No game data. It needs a GL context: run it under Xvfb with Mesa llvmpipe, which tools/docker/linux.Dockerfile installs (xvfb, libgl1-mesa-dri); CTest registers it through xvfb-run when that exists. vr_render_harness <dir> writes the dumps under <dir> (build/vr-harness/ for the manual run, <build>/vr-harness/ under CTest). It asserts the mono replay equals the flat renderer, that recording draws nothing, markers centred per eye at head yaw 0, 90 and 180 against an analytic pixel expectation, the disparity sign and size, sprite facing, the sky at infinity, the fade measured from the eye, HUD alpha, diag_yaw, recentre, standby, force_mono, timing and the dump files. Mutation checks (wide cull off, no sky re-translate, game-camera sprites, shake applied, alpha forced to 1, fade from the game camera) each fail it.
The cockpit tests (tests/vr/vr_cockpit_harness.inc, included by the harness) also link the game's own image loader, UI, HUD and the cockpit module, with the game's globals stubbed. They run on synthetic hulls with the names and triangle counts of two real ships. With WIPEOUT_VR_TEST_DATA set to a directory that holds wipeout/... (the user's data; never commit it or what comes out of it) they also run on the 8 real ships and render two to <out>/cockpit-real/; tools/mockup/compare_cockpit_render.py compares those with the page's shots. WIPEOUT_VR_HARNESS_ONLY=cockpit runs just the cockpit tests. On Apple's GL 4.1 the harness falls back from 4.3 to 4.1 for the window but is not run there: use the Docker build.
GL profile decision (2026-10-02): core 4.3 context plus GLSL 330 shaders in the VR build. The alternative was a compatibility-profile context that keeps the game's GLSL 1.10 shaders.
- SteamVR's
xrGetOpenGLGraphicsRequirementsKHRreports 4.3 to 4.6, so the VR build must ask for at least 4.3. - Evidence for core: the F-Zero X M0-A probe on the Frame asked SDL for a 4.1 core (forward-compatible) context and got Zink on Turnip, Mesa 26.3.0-devel, 4.6 core, a GLX context, and
xrCreateSessionwith the Xlib binding succeeded. - Nothing measured a compatibility profile on the Frame, and Mesa's Zink documentation does not say whether it offers one. Choosing it would bet the phase-0 gate on an unmeasured path.
- Cost of core:
render_gl.calready uses VAOs and VBOs only, so the game's GL calls are valid in core. Only the shader text changes:src/vr/vr_shaders_gl330.hhas#version 330 corevariants of the game and the two post-effect programs (default, CRT), selected inrender_gl.cby#if WIPEOUT_VR. The sampler is renamedtex, sincetextureis a built-in function in 330. Edits to upstream's file are the fences and one include. - Host evidence: in the Linux arm64 container under Xvfb with Mesa llvmpipe, the VR binary asked for 4.3 core, got a 4.5 core GLX context, built the Xlib binding, found no OpenXR runtime and fell back to flat, and
render_init()compiled and linked all five GLSL 330 shaders (it reachedgame_init(), which stopped at the missing game data). The Frame's Zink driver has not compiled them.
GL binding. SDL's video driver is forced to x11 (SDL_VIDEODRIVER, unless set). After SDL_GL_CreateContext, vr_gl_init() checks SDL_GetWindowWMInfo says x11, then reads glXGetCurrentDisplay/Context/Drawable and the context's FBConfig (glXQueryContext plus glXChooseFBConfig) through SDL_GL_GetProcAddress, and builds an XrGraphicsBindingOpenGLXlibKHR (visualid 0, as the F-Zero X probe did). The runtime's minimum GL version is checked against the context's GL_MAJOR_VERSION/GL_MINOR_VERSION before xrCreateSession.
Swapchains. Colour only: GL_SRGB8_ALPHA8 if offered, else GL_SRGB8, else RGBA8 with a warning. Eye size is vr.eye_size (2160) times vr.resolution_scale, clamped to the runtime's maximum, with the recommended aspect (vr_xr_eye_scale): the swapchain images are the final render size and the eyes are replayed straight into them. Copies into the images run with GL_FRAMEBUFFER_SRGB off (and restore it), so the already gamma-encoded frame lands unconverted.
Extensions. XR_KHR_opengl_enable is required. XR_VALVE_frame_controller_interaction (enabled for the actions milestone) and XR_FB_display_refresh_rate are enabled only when the runtime offers them. The refresh request and the not-held recommendation are in the Display note (section 7).
Linux ARM64 build. tools/docker/linux.Dockerfile (Ubuntu 22.04, gcc-12, CMake 3.31.6, SDL2 2.30.11 built from source, GLEW from the distribution) and tools/docker/build-linux.sh [off|on|package|abi|verify|all]. tools/check_linux_abi.py (copied from F-Zero X) fails above glibc 2.35, on an unbundled library, or on a CPU level the code does not use.
Flat build unchanged. tools/verify_flat_build_unchanged.sh builds upstream's HEAD (b425ecb) and the working tree with WIPEOUT_VR=OFF, the same compiler and flags (-ffile-prefix-map so __FILE__ matches), and compares every object file. All are byte-identical except render_gl.c.o: the fences shift later lines, and upstream's die() prints __LINE__. For that file the preprocessed source is identical once the line number in those messages is masked, and the disassembled code is identical.
Device. tools/steam_frame/deploy.sh rsyncs build/package/WipEoutVR/ to steamos@10.86.200.233:~/devkit-game/WipEoutVR/ and registers the devkit title WipEoutVR through ~/devkit-utils/steam-client-create-shortcut (compat tool SteamLinuxRuntime_4-arm64, steam_play=0). LAUNCH-STEAM-FRAME.sh sets SDL_VIDEODRIVER=x11, logs to run-stdout.txt and execs the binary. Launch from the Frame's Steam library, never over SSH.