Skip to content
 
 

Repository files navigation

CustomHeadsetOpenVR — Dream Air Lighthouse build

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.

Scope — what applies to which headset

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 dreamAir config section only — the Shader and Image tabs are headset-agnostic, since customShader is 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.


Verified working

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.

What this fixes

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.

Rotated-panel eye targets

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.

Distortion shader interface drift

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 sevenmatRendered 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.

Headset volume buttons drive the SteamVR dashboard

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.headsetButtonMode1 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.

The rest

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.

The two headset modes

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.

Why SteamVR reports a lower resolution in mode 1

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.


The launcher

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.

Launch tab

Launch tab

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.

Shader tab

Shader tab

Filters available: None, NearestNeighbor, FXAA2, FXAA2CAS, LumaSharpen, CAS.

Image tab

Image tab

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.

Headset tab

Headset tab

Pictured at stock valuesDream Air Default, eye rotation 2, Max FOV X 98, Max FOV Y 88. 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, and movementTime is the knob.
  • Burn-in prevention takes its offset from the gap between fovMaxX and maxFovX (BaseHeadset.cpp:558), so it has nothing to work with when maxFovX sits 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.

A measured wide-FOV configuration

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.

Measured against the alternatives

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.

Custom distortion profiles

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.

How it handles settings.json

  • 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 to settings.json.bak each save.
  • UTF-8 without a BOM, read with utf-8-sig so 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.


Building

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() prefers shaderDevPath when it exists, so on a machine with the repo checked out the driver compiles the .hlsl from the source tree at runtime, not from the installed resources folder. Shader edits take effect without rebuilding the DLL — keep both copies in sync to avoid confusion.

Installing

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.


Diagnostics

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.


Donations, Support and Tips

If I have managed to help you in any way with this custom Pimax Launcher, please consider a small tip — it helps a lot. ko-fi


Branches

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.

Staying current with upstream

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.

Licence

GPL v2, as upstream. See LICENCE.md.

About

A custom standalone Pimax Launcher for the Dream Air Lighthouse, and a platform for other headsets.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages