An OpenXR API layer for Windows that gives you two ways to see what your VR game is actually doing each frame: an in-headset HUD with live FPS / frametimes / GPU+CPU load (head-locked or world-locked), and a per-frame CSV log covering every CPU and GPU segment of the OpenXR frame cycle. Both features are independent — you can run either, both, or neither.
The layer sits between the game and the OpenXR runtime, so it works across SteamVR / WMR / Oculus / Pimax / Varjo without per-runtime patches. It never modifies the frame content the game submits; it only observes timings and (optionally) composites its own quad on top.
Download the latest Setup.exe from
Releases
and run it. The installer registers the layer under HKLM so every
OpenXR runtime on the machine picks it up, and creates an Add/Remove
Programs entry for clean uninstall.
The overlay (HUD) and the CSV log both ship enabled in hotkey mode —
armed but dormant. Nothing is drawn and no CSV is written until
you press the toggle in-game (Ctrl+Shift+O for the overlay,
Ctrl+Shift+T for the log). See Settings below to make a feature
always-on (auto) or turn it off entirely.
- Install via
Setup.exe. It drops a defaultsettings.jsoninto%LOCALAPPDATA%\XR_APILAYER_MLEDOUR_xr_telemetry\. - Launch your OpenXR game. Both features ship enabled in hotkey mode, so
nothing shows yet — press
Ctrl+Shift+Oto toggle the overlay (it appears in the top-right of your FOV) andCtrl+Shift+Tto start/stop a CSV recording (files land in…\sessions\). - Want a feature always-on instead of hotkey-toggled? Edit
settings.jsonand set itsmodeto"auto". To turn a feature off entirely, set itsenabledtofalse.
Until you toggle something on, the layer just polls for the hotkeys a few times a second — no CSV, no HUD, negligible overhead.
Because the loader injects this DLL into every OpenXR app on the machine, there are two ways to make it stand down without uninstalling:
- Fully inert (loader level). Set the environment variable
DISABLE_XR_APILAYER_MLEDOUR_xr_telemetry=1(the manifest'sdisable_environment). The OpenXR loader then never loads the layer into the process at all — the safest option if a game or its anti-cheat misbehaves with any third-party layer present. - Pure pass-through (settings level). Set both
log.enabledandoverlay.enabledtofalseinsettings.json. The DLL still loads but every call forwards straight through — no hotkeys polled, no instrumentation.
The layer reads its configuration from
%LOCALAPPDATA%\XR_APILAYER_MLEDOUR_xr_telemetry\:
settings.json— the global template. Edit this to change the defaults for future games.<app>_settings.json— auto-created the first time you run a given game, copied from the template. Lets you keep different settings per game (DCS, MSFS, iRacing, …) without them clashing.
Slug rules: spaces and special characters become _, uppercase is
lowered. DiRT Rally 2.0 → dirt_rally_2_0_settings.json.
Full schema:
{
"log": {
"enabled": true,
"mode": "hotkey",
"hotkey": { "key": "T", "modifiers": ["ctrl", "shift"] }
},
"overlay": {
"enabled": true,
"mode": "hotkey",
"hotkey": { "key": "O", "modifiers": ["ctrl", "shift"] },
"refresh_hz": 10,
"position": "head_top_right",
"scale": 1.0,
"anchor": "head",
"offset_x": 0.0,
"offset_y": 0.0
}
}The parser is permissive — missing keys, wrong types, unknown enum values all fall back to the documented defaults silently rather than disabling the feature, so a typo never kills your session.
The overlay is a HUD in a corner of your FOV, two-column fpsVR-style
layout. By default it's head-locked — it follows your view and
stays in the same spot. You can also pin it world-locked so it
freezes in the room in front of you (see anchor below).
| Row | Field | Meaning |
|---|---|---|
| Header | FPS | Instant frame rate from the last frame's duration (1e9 / frame_total_ns). White, the eye-catcher. |
| Header | FPS AVG | Mean FPS over the refresh window (10 Hz by default). |
| Header | P95 | 95 % of frames hit at least this FPS — the slow 5 % drop below. Sliding 30 s window. |
| Header | P99 | 99 % of frames hit at least this FPS. The worst 1 % drop below. |
| Header | P99.9 | The single worst ~0.1 % — typically a handful of specific spike frames in the window. Matches fpsVR / SteamVR "Frame Timing" convention. |
| GPU Frametime ms | 6.7 ms + bars |
Mean GPU time spent on the app's xrBeginFrame → xrEndFrame draws over the refresh window. Bars: 120-sample rolling histogram, one bar per frame. Y axis spans 0..2× period; the white midline marks the budget. |
| CPU Frametime ms | Render X.X ms / App X.X ms + bars |
App = the per-cycle total (frame_total - wait_block) — the same number OpenXR Toolkit / fpsVR / PresentMon call app CPU (hence the label), and what drives CPU LOAD. Render = the app's render-recording window (render_ns — xrBeginFrame returning to the app → xrEndFrame), matching OXRT's render CPU. Render is a sub-part of App; the rest (the app's pre-render / between-frames work + the runtime's begin/end calls) isn't broken out. |
| Bottom | GPU TEMP | Temperature in °C from the active GPU sensor. |
| Bottom | GPU LOAD | Utilisation % derived from gpu_headroom_pct. Cyan < 80 %, orange 80–89 %, red ≥ 90 %. |
| Bottom | VRAM | vram_used / vram_budget as a %. Same tier colours as GPU LOAD. |
| Bottom | CPU LOAD | Per-cycle CPU utilisation % derived from headroom_pct (the app's CPU work vs. the frame budget — distinct from the system-wide CPUs LOAD reading). Same tier colours as GPU LOAD. |
| Bottom | CPUs LOAD | Utilisation % of the busiest logical processor (fpsVR's "CPUs"). Sampled system-wide via a documented user-mode NT call (NtQuerySystemInformation) — no driver, no elevation. A high CPUs LOAD next to a tame CPU LOAD is the classic single-thread-bound signature most VR titles hit. -- only when the sampler couldn't initialise. Same tier colours as GPU LOAD. |
CPU breakdown — comparing to OpenXR Toolkit. Both terms map 1:1 to
OXRT's two CPU read-outs: App = OXRT app CPU (the full per-cycle
time), and Render = OXRT render CPU (the app's own render-recording
between xrBeginFrame and xrEndFrame). Render is measured from after
the downstream xrBeginFrame returns, so — exactly like OXRT — it
excludes the runtime's begin call (which can be ~0.5 ms on a young
runtime). Render is always ≤ App; the difference is the app's pre-render
/ between-frames work plus the runtime's begin/end calls, which aren't
broken out. Caveat: a passive layer only sees the OpenXR-calling thread,
so neither number includes simulation the engine runs on other threads.
Bar colour code (per-sample, not overall):
| Colour | When |
|---|---|
| Cyan / blue gradient | Frametime ≤ 80 % of the runtime's predicted display period |
| Orange | 80 – 100 % of budget — close to missing the deadline |
| Red | ≥ 100 % — frame busted the budget. Reprojected / stuttered frames cross the white midline. |
The budget is anchored, not auto-normalised — a single spike won't squash the rest of the bars, and a stretch where every bar is red honestly means "you're busting every frame here" (e.g. a 60 Hz menu cap on a 90 Hz HMD).
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | true |
Master switch. Ships on in hotkey mode, so the HUD never paints uninvited — it stays hidden until you press the combo. Set false to disable the overlay entirely. |
mode |
string | "hotkey" |
hotkey (the default) = HUD hidden until you press the combo, toggles on/off on each subsequent press. auto = HUD visible the whole session when enabled=true. |
hotkey.key |
string | "O" |
Main key. Recognised: A–Z, 0–9, F1–F24, Space, Tab, Enter, Escape, Backspace, Insert, Delete, Home, End, PageUp, PageDown, Up, Down, Left, Right. Punctuation is intentionally unsupported (locale-dependent). |
hotkey.modifiers |
string[] | ["ctrl", "shift"] |
Modifiers required IN ADDITION to the main key. Recognised: ctrl, shift, alt, win. Must match exactly — Ctrl+Alt+Shift+O does NOT trigger a Ctrl+Shift+O binding. |
refresh_hz |
int | 10 |
How often the displayed numbers update. Clamped to [1, 60]. 10 Hz matches fpsVR — fast enough that the numbers track reality, slow enough to be readable in motion. |
position |
string | "head_top_right" |
Corner of the FOV. Recognised: head_top_right, head_top_left, head_top_center, head_center. Anything else falls back to head_top_right. With anchor: "world" this picks where the panel lands at the moment it's summoned. |
scale |
float | 1.0 |
Multiplier on the default quad size. Clamped to [0.5, 2.0]. |
offset_x |
float | 0.0 |
Fine-tune the HUD's horizontal placement, in metres at the 1 m quad distance, on top of position. + = right, − = left. The stock corner already hugs the edge; use this to push it further out (or back toward centre) without a rebuild. Clamped to [-1.0, 1.0]. |
offset_y |
float | 0.0 |
Same as offset_x but vertical: + = up, − = down. Clamped to [-1.0, 1.0]. |
anchor |
string | "head" |
Reference frame. head = the HUD is attached to the headset and follows your gaze (the stock behaviour). world = the HUD freezes in the play space in front of you the moment it turns on and stays there as you move and look around. It always hangs upright — a tilted head when it's summoned won't leave it pitched or rolled — but you aim its height with your gaze: look up as you summon it and it anchors higher, look down and it anchors lower. To re-centre a world-locked HUD, toggle it off and on again (auto mode: it re-anchors at the start of each session; hotkey mode: each time you press the combo to summon it). If the headset isn't tracking when it's summoned the panel waits for tracking, and falls back to head-locked after a couple of seconds rather than never appearing. Anything other than world falls back to head. |
Graphics-API support. D3D11 hosts paint the HUD with GPU shaders (a
prebuilt glyph atlas + instanced quads) straight into a BGRA8 swapchain.
D3D12 hosts go through D3D11On12 so those same D3D11 shaders can render
directly into the D3D12 swapchain image.
Vulkan and OpenGL hosts are not supported — the layer logs
overlay disabled — Vulkan/OpenGL hosts not supported by the renderer
and the CSV-logging path keeps running.
Hotkey caveats. Hotkeys are polled inside xrEndFrame (game must
have focus + be rendering). On European AZERTY / QWERTZ keyboards
AltGr reports as Ctrl + Alt, so a Ctrl + letter binding will also
fire on AltGr + letter — prefer Shift + F-key if you hit this.
The layer does NOT consume key presses (no RegisterHotKey), so a
game binding on the same combo will fire alongside the layer toggle;
pick a combo your game doesn't claim, or add alt to push into the
under-used Ctrl+Alt+Shift+letter range.
The layer writes one CSV row per frame, capturing every CPU + GPU segment of the OpenXR frame cycle. Files live at:
%LOCALAPPDATA%\XR_APILAYER_MLEDOUR_xr_telemetry\sessions\
YYYY-MM-DD_HH-MM-SS.mmmZ_<AppName>.csv
One file per session in auto mode, one file per recording window in
hotkey mode. Openable directly in Excel / Pandas / LibreOffice.
Pandas reads the footer transparently with pd.read_csv(path, comment='#').
frame,timestamp_qpc,wait_block_ns,pre_begin_ns,app_cpu_ns,end_frame_ns,frame_total_ns,gpu_time_ns,period_ns,headroom_pct,gpu_headroom_pct,should_render,gpu_temp_c,vram_used_bytes,vram_budget_bytes,cpus_max_pct,render_ns
0,18452119837601,0,153244,6041122,287413,0,0,11111111,100.00,100.00,1,67.5,6079217664,8589934592,nan,5797878
1,18452121034711,3128905,148902,6112874,294118,11969012,5183047,11111111,20.31,53.35,1,67.5,6079217664,8589934592,96.2,5873972
2,18452122156388,3204711,151088,6088423,289776,11217677,5198114,11111111,27.84,53.21,1,67.6,6079217664,8589934592,95.8,5847335
3,18452123277014,3198044,149837,6094811,291204,11206264,5179420,11111111,27.94,53.38,1,67.6,6079217664,8589934592,97.1,5854974
4,18452124398211,3187621,150412,6097104,290847,11212197,5191388,11111111,27.89,53.27,1,67.6,6086217728,8589934592,96.5,5856692
5,18452125519988,3175102,152017,6105844,293012,11221777,5208112,11111111,27.80,53.12,1,67.7,6086217728,8589934592,95.9,5863827
…
# session_end written=8124 dropped_try_lock=0 dropped_queue_full=0 dropped_disk_write=0The trailing # session_end footer records the total rows written and
per-cause drop counters (a frame may be skipped if the writer thread
falls behind, but in practice drops are zero on healthy hardware).
| Column | Definition | What it captures |
|---|---|---|
frame |
Sequential 0-based counter | Frame index since the session start. |
timestamp_qpc |
QueryPerformanceCounter ticks at xrEndFrame entry |
Frame-end wall clock. Convert to seconds by dividing by QueryPerformanceFrequency (typically ~10 MHz on modern Windows hosts). |
wait_block_ns |
tWaitOut − tWaitIn |
Compositor throttle. Time the runtime made the app wait inside xrWaitFrame. Big = compositor has headroom and is rate-limiting the app (good). Small = app is the bottleneck. |
pre_begin_ns |
tBegin − tWaitOut |
Housekeeping. Time between xrWaitFrame returning and xrBeginFrame being called — input poll, state update. Usually ~50–300 µs. |
app_cpu_ns |
tEnd − tWaitOut |
Wait→End window = pre_begin_ns + render submission. CPU time the app spent between xrWaitFrame returning and xrEndFrame being called — render-thread heaviness. |
end_frame_ns |
Duration of the downstream xrEndFrame call |
Runtime/compositor ingest overhead. Layer composition, projection correction, compositor handoff. On mature runtimes (SteamVR / Oculus) typically a few hundred µs; on young runtimes can reach 1–2 ms. |
frame_total_ns |
tEnd_now − tEnd_prev |
Full cycle duration. End-to-end wall clock of the previous frame. Includes the post-xrEndFrame work (sim, physics, AI, input polling) that app_cpu_ns can't see because it happens AFTER the app returns and BEFORE the next xrWaitFrame. 0 on the first frame. |
gpu_time_ns |
GPU timestamp delta from xrBeginFrame to xrEndFrame |
App GPU work for this frame. D3D11: D3D11_QUERY_TIMESTAMP bracketed by a D3D11_QUERY_TIMESTAMP_DISJOINT for frequency validation. D3D12: native D3D12_QUERY_TYPE_TIMESTAMP on the layer's own short command lists (no D3D11On12 wrapping). 0 for Vulkan / OpenGL hosts (not instrumented) and for the first ~4 frames (async-query warmup). |
period_ns |
XrFrameState.predictedDisplayPeriod |
Target frame budget reported by the runtime. ~11.11 ms @ 90 Hz, ~8.33 ms @ 120 Hz, ~13.89 ms @ 72 Hz. Constant for a given session. |
headroom_pct |
(1 − (frame_total_ns − wait_block_ns) / period_ns) × 100 |
CPU % of the frame budget NOT spent on app CPU work this cycle. Matches fpsVR / OpenXR Toolkit semantics. Negative ⇒ CPU-bound. Falls back to (1 − app_cpu_ns / period_ns) × 100 on the first frame. Reads 100.00 when period_ns == 0 (transient at session start) — filter on period_ns > 0 to exclude. |
gpu_headroom_pct |
(1 − gpu_time_ns / period_ns) × 100 |
GPU % of the frame budget NOT spent on app GPU work this cycle. Negative ⇒ GPU-bound. 100.00 when gpu_time_ns == 0 (no D3D binding, disjoint range invalid, query not yet ready) — filter on gpu_time_ns > 0 to exclude unmeasured rows. |
should_render |
XrFrameState.shouldRender as 0/1 |
Whether the runtime asked the app to render this frame. 0 = skipped (focus loss, scene transition). Typically filtered out for steady-state analysis. |
gpu_temp_c |
GPU temperature in °C, 1 decimal | Latched at the snapshot's refresh cadence. nan (pandas reads as np.nan) if the GPU vendor's sensor path is unavailable. |
vram_used_bytes |
GPU dedicated memory currently allocated, bytes | From DXGI's local-memory budget query. 0 if the query failed. |
vram_budget_bytes |
OS-suggested VRAM budget for the process, bytes | From the same DXGI query. The renderer uses this for the bottom-row VRAM percentage (used / budget). |
cpus_max_pct |
Busiest logical processor's utilisation %, 1 decimal | The overlay's CPUs LOAD. Latched at the sampler's ~1 Hz poll cadence (system-wide, via NtQuerySystemInformation). nan until the first reading and on hosts where the sampler couldn't initialise. A high cpus_max_pct with a tame headroom_pct is the single-thread-bound signature. |
render_ns |
tEnd − tBeginExit |
App render-recording. xrBeginFrame returning to the app → xrEndFrame entry — the app's own draw submission, EXCLUDING the runtime's xrBeginFrame call. Matches OpenXR Toolkit's render CPU and is surfaced in the overlay as Render. Tighter than the derived app_cpu_ns − pre_begin_ns, which also counts the runtime begin call. Appended last (after cpus_max_pct) to keep the column order stable. |
Stitched chronologically, the CPU columns cover 100 % of the cycle. The GPU column runs in parallel — the GPU processes the app's draws while the CPU has already moved on to the next frame's sim work:
CPU timeline:
xrEndFrame_prev ↓
├──────── post_end ────────────┐
↓
xrWaitFrame ↓
├ wait_block ┤
↓
├ pre_begin ┤
↓ xrBeginFrame
├ render_submission ┤
↓ xrEndFrame ↓
├ end_frame ┤
↓
←──────────────────── frame_total ────────────────────────────→
GPU timeline (parallel, may overlap with the next CPU frame):
xrBeginFrame ↓
├──── gpu_time ───┤
↓ xrEndFrame
The app's draw-submission window is stored directly as render_ns
(xrBeginFrame returning → xrEndFrame, excluding the runtime's begin
call — the overlay's Render, = OXRT render CPU). The looser
app_cpu_ns − pre_begin_ns measures the same window but also counts the
runtime's xrBeginFrame call.
One segment isn't stored — compute it in analysis when you need it:
post_end_ns = frame_total_ns − wait_block_ns − pre_begin_ns − app_cpu_ns − end_frame_ns— time spent OUTSIDE OpenXR calls (game simulation, physics, AI, scripting, event polling).app_cpu_nsalone can't see this — it only covers the wait→end window.
| Symptom | Where to look | Reading |
|---|---|---|
| App feels stuttery | frame_total_ns > period_ns × 1.5 rows |
Missed deadlines. Drill into which segment dominates. |
| CPU-bound vs GPU-bound | Average headroom_pct vs gpu_headroom_pct |
Whichever is lower is your bottleneck. Both low + similar = balanced at the limit, optimise both. Only CPU low = simplify sim / submission. Only GPU low = drop shader complexity / resolution. |
| Render-thread heavy | render_ns close to period_ns |
Too many draw calls / state changes / GPU-queue stalls. Profile with PIX / RenderDoc. |
| Sim heavy | frame_total_ns − wait_block_ns − app_cpu_ns − end_frame_ns dominates |
Physics / AI / scripting between frames is the bottleneck — game-logic thread, not the renderer. |
| Runtime overhead | end_frame_ns > ~1 ms consistently |
The runtime itself is slow ingesting frames. Switching runtimes (SteamVR ↔ Oculus ↔ vendor) usually moves this number. |
| Healthy session | wait_block_ns ≈ period_ns × 0.3, frame_total_ns ≈ period_ns |
App finishes early, runtime throttles, equilibrium. |
GPU timestamps are asynchronous — a query issued at xrBeginFrame_N
returns 1–3 frames later, so the layer defers each row by ~3–4
frames to keep gpu_time_ns aligned with its own frame_index. The
last ~4 rows of a session flush with gpu_time_ns = 0 because the GPU
result wasn't ready at shutdown — filter on gpu_time_ns > 0 before
computing GPU averages.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | true |
Master switch for the CSV. Ships on in hotkey mode (no file is written until you press the combo). Set false to skip logging entirely; with both log.enabled and overlay.enabled set to false the layer is a pure pass-through. |
mode |
string | "hotkey" |
hotkey (the default) = no CSV until you press the combo; each press starts/stops a recording window (one fresh file per window — short sessions don't merge into one giant log). auto = one CSV per session, opened at session start and closed at session end. |
hotkey.key |
string | "T" |
Main key. Same set of recognised names as the overlay hotkey. |
hotkey.modifiers |
string[] | ["ctrl", "shift"] |
Modifiers. Same set as the overlay hotkey. |
The two hotkeys default to different combos (Ctrl+Shift+O for
the overlay, Ctrl+Shift+T for the log) so users running both in
hotkey mode can drive them independently without a chord collision.
Every transition is logged (xr_telemetry: hotkey pressed — log RECORDING/STOPPED) so support sessions can see when the user thought
they started or stopped something.
The settings file is parsed at xrCreateInstance and stays cached for
the rest of the session — no filewatch reload. A filewatch adds steady
jitter to the frame loop (we observed this on the sibling fov_crop
layer's live_edit flag), and the whole point of xr_telemetry is to
measure frame timings — polluting the measurement with its own
bookkeeping defeats the purpose. Restart the game to apply a new
settings file.
MIT — see LICENSE.
The framework code (openxr-api-layer/framework/, the dispatch
generator, module.def, the entry point, the logging helpers) is the
work of Matthieu Bucchianeri (mbucchia), Copyright © 2022-2023, and
ships under his MIT terms alongside ours.
The bundled font (Rajdhani-SemiBold.ttf) is licensed under the SIL Open
Font License v1.1 — see
openxr-api-layer/fonts/OFL.txt.
