A fork of sboys3/CustomHeadsetOpenVR maintained for a Pimax Dream Air with Lighthouse tracking, plus a standalone launcher and settings GUI.
main tracks upstream exactly and is never committed to. All changes live on custom-build,
which is a small patch set rebased on top.
Upstream supports a long list of headsets. This fork does not narrow the driver, but it has only been tested on one, and the launcher is written for one.
| Applies to | |
|---|---|
| The driver | Everything upstream supports. No headset support has been removed — the Crystal, SLAM and MeganeX paths are untouched, just unexercised here. |
| Shader interface fix | Any headset using the custom shader. SceneTextureIndexConstantBuffer_t is read on all of them, so this layout bug is not Dream Air specific. |
| Rotated-panel fix | Pimax headsets with displayRotation 1 or 3. Conditional, so it is a no-op on the rest. |
| Buffer guard, include path, shader logging | Everything. |
| The launcher | Dream Air. It is usable elsewhere with two caveats below. |
The launcher's Dream Air assumptions are narrow and easy to change:
- the launch sequence stops early when
connectedHeadset == 4; on another headset it simply runs the full 40 seconds instead, which still works - the Headset tab exposes the
dreamAirconfig section only — the Shader and Image tabs are headset-agnostic, sincecustomShaderis global
Everything else about it — locating Steam, the enable_no_render_mode toggle, the status panel —
applies to any Pimax headset. If you want it for a different one, those are the two places to look.
Tested only on a Pimax Dream Air with Lighthouse tracking, against SteamVR 2.16.7 (stable) and Pimax Play v2.0.3 with headset firmware 1.0.4. Anything else is untested, not unsupported.
A 1 h 50 m session on 14 Aug 2026, in the harder configuration — driver rendering, custom shader active, Lighthouse tracking:
| Mode | no_render=1, driver renders |
| Tracking | Lighthouse, Active HMD set to lighthouse.LHR-3847B5BC |
connectedHeadset |
4 — the driver owns the panel |
| Base stations | both present for the whole session |
| Shader | LumaSharpen, strength 3.0, clamp 0.1, recompiled live mid-session |
| Headset buttons | 79 presses, no misfires |
Zero of every tracking failure signature across the session: no Resetting tracking, no
IMU misalignment unreasonably large, and no rejected reads of the headset's Lighthouse receiver.
Hardware: Pimax Dream Air with the Lighthouse faceplate, two base stations, the SteamVR 2.17.7 beta, Pimax Play with Open Port enabled. Launched through the launcher's Legacy Lighthouse Launch.
Re-verified 20 Aug 2026 on the current stack — SteamVR 2.16.7 stable and Pimax Play v2.0.3 — same mode, wide-FOV configuration and full shader stack, after the v2.0.3 profile split and the second shader interface change described below were fixed.
Two of the changes here are genuine upstream bugs, both found by measurement rather than
inspection, and both reproduce on sboys3's own stock 1.3.0-beta.2 binary.
HidModifier wrote eye_target_width/height_in_pixels into the Lighthouse config unswapped
for Pimax headsets, while the MeganeX branch ten lines above it swaps, and
PreDisplayComponentGetWindowBounds swaps as well. On a Dream Air (displayRotation = 3) the
two halves of the driver therefore described the same panel differently:
lighthouse config (HidModifier) -> 7680 x 3552
display component (BaseHeadset) -> 7104 x 3840
Both figures are truthful descriptions of the same panels — they differ by rotation, not by being wrong. The Dream Air's Sony micro-OLED panels are 3840 × 3552 each, so 7680 × 3552 side by side. They are mounted rotated 90°, so the scanout is 3552 × 3840 each, and PVR reports the pair as 7104 × 3840:
CustomHeadsetOpenVR: Panel Resolution: 7104x3840 (Orientation: 0 deg)
Same 13,639,680 pixels per eye either way. The bug is that eye_target_* needs the scanout
dimensions, and HidModifier wrote the nominal ones. SteamVR then searched for a display mode
of 7680 × 3552 — a real description of the panels, but not a mode any display presents — logging
Could not find monitor of expected size 7680 x 3552 every two seconds, reporting the headset as
"running as a monitor" rather than direct mode, and rendering at the wrong geometry.
The SteamVR 2.17.7 beta (10 Aug 2026) recompiled all 178 of its compositor shaders. The
replacement shader's SceneTextureIndexConstantBuffer_t declared five float4x4 per eye
where that build declared seven — matRendered and matInvRendered as pairs:
SteamVR this shader (before)
matProj 48 0
matInvProj 112 64
matRendered0 176 128 <- single matRendered
matRendered1 240
matInvRendered0 304 192 <- single matInvRendered
matInvRendered1 368
matTexDetails 432 256 <- 128 bytes adrift
CB2[175] CB2[127]
g_SceneTextureData is indexed dynamically by IN.param4, so a stride 256 bytes short per
element misread every element past the first and displaced matTexDetails — read at line 650 for
textureRange — inside all of them, including [0].
Symptom on a Dream Air: a flickering band at the top of the view, at a different elevation in each
eye, in games but not the compositor void, only with customShader enabled.
The same commit adds noperspective to the three TEXCOORD inputs, which SteamVR declares and
this shader did not. That alone did not fix the flicker; the layout did.
That was not the last word. Stable SteamVR 2.16.7 (build 23791826) uses the original five
matrices per eye — the seven-matrix layout was a 2.17.7-beta experiment — so on the stable
channel the seven-matrix shader produced the same flicker signature in reverse. The shipped
shader now declares five, matching stable. When the flicker returns after a SteamVR update,
disable the custom shader first, then re-match the layout using the method under
Diagnostics. The driver compiles the .hlsl at runtime, so the fix is an edit
to the installed shader file and a SteamVR restart, no rebuild.
Upstream reads all four headset buttons — power, volume up, volume down, double tap — out of
hmd_buttons (PVR) or byte 49 of the direct HID report, then throws most of them away:
systemButton = isButtonPressed(Button_VolumeUp) && isButtonPressed(Button_VolumeDown);Only the volume pair held together ever reached SteamVR, as /input/system/click. A single
press of either button did nothing, which is why the buttons feel dead once the driver owns the
panel and Pimax Play is no longer handling them.
The individual presses are now exposed as their own components, /input/volup/click and
/input/voldown/click, declared in pimaxhmd_profile.json so they show up in SteamVR's binding
UI. The shipped compositor bindings are:
| Button | Action |
|---|---|
| Volume + | Toggle the SteamVR dashboard |
| Volume − | Left click where you are looking (lasermouse) |
| Both together | System click, as before |
| Double tap the headset | Toggle room view, as before |
So the dashboard opens with Vol+, you aim with your head, and Vol− clicks — head-gaze menu control from the headset alone, no controllers.
While both buttons are held the chord owns the press and the individual components stay low, so the pair does not also fire the dashboard binding on the way through.
Set by dreamAir.headsetButtonMode — 1 separate (default), 0 for upstream's chord-only
behaviour. It is on the launcher's Headset tab. Rebind under SteamVR → Settings → Controllers →
Manage Bindings → Pimax HMD.
Applies to the Lighthouse and SLAM paths both. Button transitions are logged to the SteamVR
driver log as HMD buttons: 0x.., which is the fastest way to confirm the hardware is reporting
them at all.
Caveat: this takes the volume buttons over. While SteamVR owns the panel they no longer change
headset volume — use headsetButtonMode = 0 if you would rather keep upstream's behaviour.
| Commit | What |
|---|---|
HidGetFeatureReportHook short-buffer guard |
Reads data[0] and writes data[1..2] before checking length. length is a size_t, so a caller passing 0 or 1 also underflows the length - 2 sizing the chunk copy. Hardening — driver_lighthouse supplies 65 in practice. |
PVR_API.h include path |
AAPVRBlocker.cpp hardcoded C:/Program Files/Pimax/Sdk/Include/PVR_API.h, so the project would not compile for anyone without the SDK at that exact path. Every other file already uses <PVR_API.h> from the ThirdParty/pvr submodule. |
generalHeadset.forceBlockAapvr |
Blocks aapvr unconditionally. Gets the driver the HMD and tracking but not the display, so on its own it yields a black screen. Defaults false; kept because it isolates the tracking half of the problem. |
| Shader diagnostics | See Diagnostics. |
This is the single most important thing to understand about running the driver on a Lighthouse
Dream Air. Which mode you are in is decided by enable_no_render_mode, because it decides who
owns the headset panel.
Pimax Play v2.0.3 split the file that carries it. PiService reads
%LOCALAPPDATA%\Pimax\PiService\profile.json at startup to decide whether to launch pi_server
with the no_render argument, while pi_server itself uses
%LOCALAPPDATA%\Pimax\runtime\profile.json — and the v2.0.3 updater deletes the PiService copy,
so a value written only to the surviving runtime file is silently ignored. The launcher's toggle
writes both, creating the missing one.
enable_no_render_mode = 1 enable_no_render_mode = 0
pi_server does not render pi_server renders and holds the panel
| |
this driver takes the panel aapvr drives the headset
connectedHeadset: 4 connectedHeadset: 1
shader / FOV / IPD / distortion driver loads but rides along as a shim,
all apply none of its settings do anything
| |
needs the launch sequence SteamVR starts normally, first time
Pimax Play rewrites that file, so read the mode from the newest
%LOCALAPPDATA%\Pimax\runtime\pvr_srv_log_*.txt (runtime mode no_render:N), not from the file.
The launcher shows the live value and warns when it disagrees with what you asked for.
In mode 1 the driver still cannot take the panel while pi_server is alive — hence the launch
sequence, which holds pi_server down until SteamVR has claimed the display in direct mode.
Blocking aapvr alone is not enough: you get tracking and a black screen.
The most common question about this fork, so it is worth stating plainly: in mode 1 SteamVR's "Resolution Per Eye" reads lower than in Pimax native, and nothing is wrong. On a Dream Air:
| Renderer | SteamVR 100% | Pixels |
|---|---|---|
| This driver (mode 1, 98° FOV) | 4028 × 3380 | 13.6 M |
Pimax aapvr (mode 0) |
5188 × 4168 | 21.6 M |
| Physical panel, both cases | 3552 × 3840 | 13.64 M |
Neither figure is a panel readout — both exceed it. They are render targets: the buffer the GPU draws into before distortion maps it onto the panel. The panel is identical either way.
The difference is the formula. This driver normalises its recommendation to the panel's pixel count and only reshapes the aspect ratio to your FOV:
// keep total number of pixels the same but change aspect ratio
renderWidth = renderWidth * sqrt(targetPixels / renderPixels);Pimax's driver instead includes the distortion oversampling, so its 100% is about 1.59× the panel.
For reference this driver logs what true 1:1 density would need — Oversampling required for 1:1 distortion: 221%.
So a SteamVR percentage means something different in each mode, and comparing the two — or your
figure against another user's — is comparing two formulas, not two headsets. Check which renderer
owns your panel before drawing any conclusion: pi_server logs runtime mode no_render:N, and
vrserver.txt logs Using existing HMD aapvr.… in mode 0 against lighthouse.LHR-… in mode 1.
Note that launching SteamVR from inside Pimax Play does not put you in mode 0.
enable_no_render_mode is read when PiService starts and decides this regardless of how SteamVR
is launched.
If you want the mod's recommendation to include the oversampling, renderResolutionMultiplierX/Y
in the dreamAir section scale it (default 1.0, not exposed in the launcher). Matching Pimax's
pixel count is sqrt(21.62 / 13.61) ≈ 1.26 on both axes. That is genuine supersampling — it
costs GPU and buys anti-aliasing, not detail the lenses can resolve.
A single-file Python/tkinter app. No build step, no dependencies beyond the standard library.
PimaxLauncher/
pimax_launcher.py the app
Dream Air Launcher.bat double-click to start it
Run with python pimax_launcher.py, or use the .bat, which prefers pythonw so no console
window is left behind. It needs to be started from a shell — the Windows Store python alias is
a reparse point that Start-Process cannot resolve.
SteamVR and Pimax Play are located automatically: the Steam registry keys, then every library in
steamapps\libraryfolders.vdf, then the usual Program Files locations. Both resolved paths are
shown at the bottom of the Status panel, in red if not found.
Theme — Light or Dark, chosen top-right and remembered in
%APPDATA%\CustomHeadset\launcher-settings.json. Dark switches ttk's base theme to clam, since
the native Windows themes ignore colour options, outlines the checkboxes and input boxes in lime,
and sets the title bar dark via DWMWA_USE_IMMERSIVE_DARK_MODE.
Hover text — every setting on the Shader, Image and Headset tabs has a tooltip, on both the
label and the control. The wording is lifted from sboys3's app-field-tip entries in
CustomHeadsetGUI, so both GUIs describe each setting identically rather than in my paraphrase.
Legacy Lighthouse Launch starts Pimax Play if it is not already up, waits for pi_server to
exist, then kills pi_server every 250 ms while starting SteamVR five seconds in. It stops as
soon as info.json reports connectedHeadset: 4 rather than hammering for a fixed 40 seconds,
and restores pi_server afterwards if the service chain has not. Both of those matter: killing
pi_server ~160 times can take PiPlayService down with it, and if Pimax Play is started fresh
by the launcher the chain spawns pi_server only once, so an unguarded loop leaves the headset
with no display at all.
Closing the window is safe once the sequence has finished — the launcher writes settings.json
and profile.json to disk and the driver watches its own config directory, so nothing depends on
the launcher staying open. Settings apply live whether it is running or not.
Closing during a launch is the exception, and the window now guards it. The sequence runs on a
daemon thread, so process exit kills it outright, and restoring pi_server is the last thing it
does — right after a burst of kills. Closing at the wrong moment would leave Pimax Play without
pi_server and no obvious cause. WM_DELETE_WINDOW therefore closes immediately when idle, and
mid-launch asks first, then cancels the sequence and closes once it has tidied up.
Headset mode writes enable_no_render_mode to both profile locations (see
the two headset modes), backing each up first. The status line is
green when the running mode matches the setting and amber when it does not, because Pimax Play
rewrites these files and only a restart applies them.
Status refreshes once a second from info.json and diagnostic.json. Eye tracking: valid
means the driver is reporting live gaze angles.
Lighthouse reads do enable lighthouse tracking from pi_server's newest log. While it is
off the entire Lighthouse subsystem is down: no base stations, and Index controllers cannot be
detected either, since they reach SteamVR through it. The symptoms are identical to a hardware
fault, which is why it is worth showing rather than leaving to be found.
Red here almost always means the headset's Lighthouse receiver is sitting in its bootloader.
Check the USB product ID: PID_2300 with three interfaces is normal; PID_2301, one interface,
and the literal string LHR BL in the logs is the bootloader. It gets stranded there by any
un-graceful headset power event — firmware updates especially — and no amount of base-station
fixing, room setup or settings editing helps until it is out. The recovery that works: close
Pimax Play and SteamVR completely, unplug the headset's cable for ten seconds, plug it back in.
A Watchman that receives cold power boots straight into its application firmware; the warm
handoff is what fails, and host software probing the device blocks it indefinitely.
One v2.0.3 sting in the tail: PiService gives Lighthouse mode eleven seconds to produce a
working receiver, then gives up and persists enable_lighthouse_tracking: 0 into
runtime\profile.json — after which every session starts in surrender mode even once the
receiver is healthy. After recovering the receiver, check that key (with Pimax Play closed) and
set it back to 1, written without a BOM.
OpenXR runtime reads Khronos\OpenXR\1\ActiveRuntime — HKCU first, then HKLM, as the loader
does. It is one machine-wide value, and both Pimax Play and SteamVR offer a button that writes it,
so whichever was pressed last wins. It has to say SteamVR: this is a SteamVR driver, so pointing
OpenXR at Pimax's own runtime makes OpenXR titles bypass SteamVR entirely and the shader, FOV and
IPD settings silently do nothing, with no error to explain it. Amber if it is set elsewhere, red if
that happens in mode 1, where it actually costs you something.
OpenVR titles are unaffected either way — they talk to SteamVR directly and never consult this.
The UI follows the running mode. With no_render=0 Pimax owns the panel and the driver only
rides along (connectedHeadset: 1), so the shader never runs and the FOV, IPD and distortion
settings do nothing. Rather than leave controls that look live but are not, the launcher greys out
the Shader and Image tabs, the launch button, and the inert half of the Headset tab, and says why
on the mode line. Everything re-enables in mode 1.
The exception is the volume buttons, which keep working in either mode — their input components
are registered on the device whoever renders — so that setting stays enabled. Gating follows the
mode pi_server actually started in, not the checkbox, since Pimax Play rewrites the file.
Filters available: None, NearestNeighbor, FXAA2, FXAA2CAS, LumaSharpen, CAS.
Contrast (value, midpoint, linear-space, per-eye plus left/right pairs), saturation, gamma, R/G/B multipliers, sRGB colour and white-point correction, lens colour correction, subpixel shift, 10-bit dither, and the mura / black-level disables.
Pictured at stock values —
Dream Air Default, eye rotation2, Max FOV X98, Max FOV Y88. For the wider numbers see A measured wide-FOV configuration below.
Volume button mode (see Headset volume buttons), eye tracking, distortion profile, eye rotation, max FOV X and Y, IPD, IPD offset, hardware IPD, distortion zoom, FOV zoom, flat FOV zoom, and black level.
The tab is deliberately this small. A larger set was tried and rolled back — extra groups quietly
writing settings.json made a multi-day tracking fault far harder to isolate, and the removed
settings are all reachable by editing the file directly. Three of them are worth knowing about
even though the GUI no longer offers them:
- Stationary dimming (
stationaryDimming) is on by default and already running — it dims to 2% after 15 seconds without movement, over a 10 second fade. That is why the view can fade while you sit still reading something, andmovementTimeis the knob. - Burn-in prevention takes its offset from the gap between
fovMaxXandmaxFovX(BaseHeadset.cpp:558), so it has nothing to work with whenmaxFovXsits exactly on the ceiling. Leave a degree spare if you want it active. - Auto eye rotation (
autoEyeRotation) overwrites eye rotation with the platform's canting angle, silently undoing any FOV gained from it. Off by default.
hiddenArea (corner-rounding meshes with a test mode), displayRotation, resolutionX/Y,
subpixelOffsets, the DSC and EDID overrides, and forceBlockAapvr are likewise settings.json
territory. The last group describe the hardware or disable the display, and a wrong value is how
the rotated-panel bug looked from the outside. The launcher preserves all of them untouched when
it saves.
Distortion profile is read from info.json rather than hardcoded — the driver publishes every
profile it has with a device field, and the dropdown offers the ones valid for this headset plus
any of type Pimax, which come from the Pimax API rather than a fitted lens model.
Worth knowing what the alternatives actually do on a Dream Air, since the names invite
experimenting. Measured from info.json, at maxFovX = 105:
Dream Air Default |
Pimax Builtin |
|
|---|---|---|
| FOV delivered | 105.0 × 85.1 | 95.0 × 95.0 |
fovMax |
104.8 / 94.2 | 104.5 / 104.5 |
| 100% render target | 4399 × 3100 | 3840 × 3552 |
| 1:1 percent | 238.9% | 313.4% |
Pimax Builtin is narrower, softer, and visibly warped in the headset. Two tells that it is not
describing these optics: the FOV comes back perfectly square on both axes, and the render target is
exactly the nominal panel with no distortion oversampling. It does not list DreamAir among its
supported devices, and it is likely querying a PVR runtime that is not rendering in this mode. Use
Dream Air Default.
It is also not a route to the wider-FOV community profiles. Those replace Pimax's hmd_conf, which
changes what Pimax's own runtime reports — so they need Pimax rendering, or its OpenXR runtime, and
in either case this driver is out of the picture.
Eye rotation is the one knob that does widen the view here, and it was only reachable by hand editing before. It rotates each eye outwards, trading binocular overlap for total FOV:
combinedFovX = per-eye FOV + eyeRotation × 2
Per-eye FOV and the render target are untouched, so the extra width costs nothing to render — you
are aiming the eyes further apart, not drawing more. It survives because autoEyeRotation defaults
false; set that true and the platform's canting angle overwrites whatever you choose.
Measured on a Dream Air, Dream Air Default, maxFovX = 105:
eyeRotation |
Combined FOV | Overlap | 100% render |
|---|---|---|---|
| 2 (default) | 109.0 | 101.0 | 4399 × 3100 |
| 4 | 113.0 | 97.0 | 4399 × 3100 |
At 4 the centre still fuses, the image stays sharp and no warping appears. That is one unit and one observer, not a general claim — the caution is that the lens model stays fitted for the default, so walk it up a step at a time and watch for objects at arm's length failing to fuse, world scale feeling wrong, or straight lines bowing when panning.
For context, the AI-generated Pimax community profiles reach 113.5° combined at 88.5° overlap and a 5460 × 4204 render target. The same combined FOV here keeps 97° of overlap at 4399 × 3100, because this profile models 105° per eye where those assume about 101.3°.
FOV zoom is the second lever, and it works differently — it divides the angle of every point on the distortion curve, so the same panel radius maps to a wider angle:
distortionsSmoothRed[i].degree /= fovZoom;That raises the ceiling rather than the delivered FOV: at 0.97, fovMaxX goes from 104.82° to
108.06° and fovMaxY from 94.23° to 97.15°, both +3.1%, with maxFovX then free to follow. It is
the same lever the Pimax community profiles reach through MetersPerTanAngleAtCenter. Their second
step, unlocking MaxRadius so the compositor can use the extra projection, is fovClamping — which
is already false for the Dream Air, so only one of the two changes applies here.
Be clear about what it does: it stretches the calibration rather than extending it, asserting the lenses bend light 3% differently from the camera fit. Distortion error is spread across the whole image, not just the edges, so the symptom to watch for is straight lines bowing near the centre while panning — not edge warping.
Combining all three, on a Dream Air with Dream Air Default:
"dreamAir": { "maxFovX": 108, "fovZoom": 0.97, "eyeRotation": 4 }| Stock | Above | |
|---|---|---|
| Per-eye FOV | 98 | 108.06 |
| Combined FOV | 102 | 116.06 |
| Overlap | 94 | 100.1 |
| 100% render | — | 4523 × 3015 (13.6 M) |
Against the community 116° profile, which is the same combined FOV: 100.1° of overlap versus 86.4°, for 13.6 M render pixels versus 22.3 M. The difference comes from modelling 108° per eye where those assume ~101.3°, so less separation is needed for the same total view.
Verified by eye in a DCS cockpit — straight lines hold, world scale reads correctly, and panel text
stays readable despite roughly 3% lower angular resolution. That is one headset and one observer, so
treat it as a starting point rather than a recommended default. fovX sits exactly on fovMaxX with
no headroom, so anything further needs a lower fovZoom and a fresh look for centre distortion.
Three configurations measured in one session with WIMFOV, same headset seating throughout, 63 mm IPD:
maxFovX 110 |
maxFovX 120 |
This configuration | |
|---|---|---|---|
| Horizontal | 103° | 102° | 110° |
| Vertical | 85° | 89° | 88° |
| Stereo overlap | 89.92% | 86.31% | 89.19% |
| Sphere coverage | 19.83% | 20.28% | 20.55% |
Raising maxFovX alone does nothing. 110 → 120 measured 1° narrower horizontally while costing
3.6 points of stereo overlap — independent confirmation of fovMaxX ≈ 104.8, since past that the
driver renders into angles the lenses never deliver.
The useful comparison is how much of the rendered projection reaches the eye:
| Config | Rendered combined | Measured | Delivered |
|---|---|---|---|
maxFovX 110 |
114° | 103° | 90% |
maxFovX 120 |
124° | 102° | 82% |
| This configuration | 116.06° | 110° | 95% |
At 120 nearly a fifth of the projection is wasted past the lens edge. Combining a stretched curve with eye rotation delivers 95%, so the 3% stretch redistributes pixels into angles the eye can actually receive rather than merely claiming wider ones.
Note the stereo overlap barely moved despite eyeRotation 4. WIMFOV reports overlap as a proportion
of the viewed area, and per-eye FOV grew alongside the separation, so the shared region grew with it.
The overlap cost of eye rotation is real in degrees but largely offset here.
WIMFOV depends on the wearer judging where the edge sits, so a few degrees of subjectivity are inherent. The effect measured is 7–8°, comfortably clear of that.
The driver reads profiles from a folder it creates and watches, so a file dropped in appears without a restart:
%APPDATA%\CustomHeadset\Distortion\<name>.json
The filename is the name — Dream Air Wide.json is selected as "Dream Air Wide". Built-in names
win if they collide. Fields read from the file are description, type, distortions,
distortionsRed, distortionsBlue, offsetX, offsetY, legacySmoothing, smoothAmount and
eyeRotationOffset; distortions is flat [angle, radius%, angle, radius%, …].
Note custom profiles are not published to info.json, which lists built-ins only — so the
launcher scans the folder as well to populate its dropdown.
Profiles/Dream Air Wide.json is the configuration above packaged
as one selection. Copy it into that folder and pick it from the Headset tab. It carries only the
stretched curve, raising fovMaxX to 108.06°; maxFovX and eyeRotation stay ordinary settings,
because how much of the ceiling you use and how much overlap you trade are preferences rather than
lens properties. Set maxFovX to 108 to use it all.
Selecting it with fovZoom at 1.0 and eyeRotation at 4 reproduces the measured configuration
exactly — 108.06° per eye, 116.06° combined, 100.1° overlap, 4523 × 3015 — which is how it was
checked. The lens calibration underneath is still sboys3's camera-fitted Dream Air Default; only
the curve angles are scaled.
- Defaults come from
Config.h, so a setting absent from the file displays the value the driver would actually use rather than a blank. - Every field is parsed before anything is written, so one bad value cannot half-save the file.
- Read-modify-write: sections the GUI does not expose — other headsets,
hiddenArea,stationaryDimming— are preserved untouched, with a backup tosettings.json.bakeach save. - UTF-8 without a BOM, read with
utf-8-sigso it tolerates one. Both nlohmann and Pimax's parser reject a BOM, and PowerShell emits one by default.
Settings apply live. The driver watches the config directory with ReadDirectoryChangesW and
reloads on change, recompiling the shader when anything it depends on moves. So you can sit in a
game, change LumaSharpen strength, press Save, and see it — expect a brief hitch as the shader
rebuilds. That mechanism is upstream's, not this fork's.
The one exception is Max FOV X/Y. The distortion rebuilds live, but a running game has already queried its projection matrix and only picks up the new one when it restarts — the game, not SteamVR. The launcher says which of the two happened after each save.
git clone https://github.com/nutbolt9/CustomHeadsetOpenVR.git
cd CustomHeadsetOpenVR
git checkout custom-build
git submodule update --init --recursive
Then build CustomHeadsetOpenVR.sln, Release|x64. Visual Studio, or Build Tools:
"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\amd64\MSBuild.exe" ^
CustomHeadsetOpenVR.sln /p:Configuration=Release /p:Platform=x64 /m
The post-build step auto-deploys to C:\Program Files (x86)\Steam\.... If Steam is elsewhere it
fails with MSB3073 exit code 4 after the DLL has already linked — harmless, just copy
output\CustomHeadsetOpenVR to your SteamVR\drivers folder yourself.
getShaderPath()prefersshaderDevPathwhen it exists, so on a machine with the repo checked out the driver compiles the.hlslfrom the source tree at runtime, not from the installedresourcesfolder. Shader edits take effect without rebuilding the DLL — keep both copies in sync to avoid confusion.
Copy output\CustomHeadsetOpenVR to <Steam>\steamapps\common\SteamVR\drivers\. SteamVR picks up
anything in that folder automatically; no vrpathreg entry is needed. Enable it under
SteamVR → Settings → Startup/Shutdown → Manage SteamVR Add-ons.
To remove it, delete that folder. Nothing else is touched.
ShaderReplacement runs inside vrcompositor.exe, injected via DllMain — the
IVRCompositorPluginProvider route is commented out upstream because it requires a display
redirect that kills video output. There is no SteamVR driver context in that process, so
DriverLog writes nowhere and the whole subsystem is silent by default.
This fork adds ShaderLog(), writing to %APPDATA%\CustomHeadset\shader.log:
[18:16:57] === ShaderReplacement::Initialize called ===
[18:16:57] Shader replacement registered: 4458424388fb63e1 -> distort_ps_layered.fxo
[18:16:57] Shader seen: 4458424388fb63e1 (5460 bytes) MATCHED -> distort_ps_layered.fxo
[18:16:57] Shader seen: 44584243ce8e0e6e (4668 bytes) no replacement
[18:16:57] Compiling ...distort_ps_layered.hlsl with 9 defines: DREAMAIR SUBPIXEL_SHIFT_DREAMAIR
PIMAX OUTPUT_RESOLUTION_X=3552 OUTPUT_RESOLUTION_Y=3840 ... FILTER_CAS ...
[18:16:57] Replacing shader bytecode: distort_ps_layered.fxo
It logs every shader the compositor creates, which matched, which was substituted, and the exact
defines each compile received. Use the log rather than inferring what compiled from settings —
the driver swaps OUTPUT_RESOLUTION_X/Y for rotated panels, so they are not the config values.
To compare the replacement against SteamVR's current shader, disassemble both and diff the declarations:
fxc /dumpbin "<Steam>\steamapps\common\SteamVR\resources\shaders\d3d11\distort_ps_layered.fxo" /Fc valve.asm
fxc /T ps_5_0 /E main /O3 /D DREAMAIR=1 /D PIMAX=1 ^
/D OUTPUT_RESOLUTION_X=3552 /D OUTPUT_RESOLUTION_Y=3840 ^
/D LENS_COLOR_CORRECTION=1 /D NO_OVERLAY_FILTER=1 /D FILTER_CAS=1 ^
/D CAS_SHARPENING=1.0 /D CAS_CONTRAST=1.0 ^
CustomHeadsetOpenVR\DriverFiles\resources\shaders\d3d11\distort_ps_layered.hlsl /Fc mod.asm
Take the defines from shader.log. fxc.exe ships with the Windows SDK, under
Windows Kits\10\bin\<version>\x64. Compare the dcl_constantbuffer, dcl_resource_texture2d
and dcl_input_ps lines and the constant buffer field offsets — that is how the layout drift
above was found, and it is the method to reach for the next time SteamVR updates its shaders.
It has been needed twice already: the 2.17.7 beta moved to seven matrices per eye, and stable
2.16.7 uses five. Check steamapps\appmanifest_250820.acf for lastupdated first — the
flicker returning always coincides with a SteamVR update, and Steam applies them silently.
If I have managed to help you in any way with this custom Pimax Launcher, please consider a small
tip — it helps a lot.
| Branch | Purpose |
|---|---|
custom-build |
The default. Upstream plus this fork's commits, the launcher and these docs. Build and release from here. |
main |
A pristine mirror of sboys3:main. Never commit to it — it exists so syncing stays a fast-forward. |
Sync main first (GitHub's Sync fork button while viewing main, or
gh api repos/<you>/CustomHeadsetOpenVR/merge-upstream -f branch=main), then replay this fork's
commits on top:
git fetch origin
git checkout main
git merge --ff-only origin/main
git submodule update --init --recursive
git checkout custom-build
git rebase main
git push --force-with-lease origin custom-build
README.md conflicts whenever sboys3 edits his — keep ours. After rebasing, rebuild and
reinstall, and check %APPDATA%\CustomHeadset\shader.log for compile failures before assuming
the shader survived.
GPL v2, as upstream. See LICENCE.md.



