Windows knows your monitor's exact refresh rate as a fraction. Every UI it ships rounds it, and two of them round it differently.
This laptop's panel runs at 453250000 / 7554800 Hz. That is 59.994970 Hz. Here is what Windows will tell you:
| Where you'd look | What it says | How wrong |
|---|---|---|
The actual signal (QueryDisplayConfig) |
59.994970 Hz | - |
Win32_VideoController.CurrentRefreshRate (WMI) |
59 |
truncated - off by 0.994970 Hz |
EnumDisplaySettings.dmDisplayFrequency |
60 |
rounded - off by 0.005030 Hz |
GetDeviceCaps(VREFRESH) |
60 |
rounded - off by 0.005030 Hz |
Settings > Display > Advanced shows 60.000 Hz. WMI says 59. Neither is the number, and the two disagree with each other by a whole hertz.
truehz prints the fraction.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File truehz.ps1
If you record at 60 fps on a 59.994970 Hz panel, you gain one frame roughly every 3.3 minutes. Your 10 minute capture holds three duplicated frames, your audio walks away from your video, and nothing in OBS explains it.
Drop to 30 fps and the same panel goes 6.6 minutes between slips - twice as good as 60. That is not a typo, and it is the opposite of what the obvious arithmetic suggests.
The obvious model asks "how far is my fps from my refresh rate?" and concludes 30 fps is 30 Hz adrift and therefore hopeless. That model is wrong. A 30 fps capture holds each frame for two refreshes, so what matters is the distance from refresh / 2:
holds = round(refresh / fps) # whole refreshes per captured frame
effective = refresh / holds
drift = |fps - effective|
At 30 fps that is |30 - 29.9975| = 0.0025 Hz, which is smaller than 60 fps' 0.0050 Hz. truehz computes this for every common capture rate and tells you which to pick.
truehz 1.0.0 - the refresh rate Windows will not print
Display 1 LGD \\.\DISPLAY1 [primary]
Internal panel, 3240 x 2160
TRUE REFRESH 59.994970 Hz (453250000 / 7554800 reduced 1133125 / 18887)
What Windows tells you instead
WMI CurrentRefreshRate 59 Hz ! off by 0.994970 Hz (truncated)
EnumDisplaySettings 60 Hz ! off by 0.005030 Hz (rounded)
GetDeviceCaps VREFRESH 60 Hz ! off by 0.005030 Hz (rounded)
Cross-checks
* DXGI GetDisplayModeList agrees exactly: 453250000/7554800
* path refreshRate matches the target mode rational
* pixel clock / raster reproduces both sync frequencies (0.00 ppm)
Signal timing
pixel clock 453.250 MHz
raster 3400 x 2222 total (blanking included)
horizontal sync 133,308.8235 Hz
desktop scaling 300% -> a capture app that is not DPI-aware sees 1080 x 720
Capture rate advice
fps holds effective drift one bad frame every dupes
23.976 x3 19.9983 Hz 3.9777 Hz 0.251 s 16.59%
24 x2 29.9975 Hz 5.9975 Hz 0.167 s 24.99%
25 x2 29.9975 Hz 4.9975 Hz 0.200 s 19.99%
29.97 x2 29.9975 Hz 0.0275 Hz 36.4 s 0.09%
30 x2 29.9975 Hz 0.0025 Hz 6.6 min 0.01%
48 x1 59.9950 Hz 11.9950 Hz 0.083 s 24.99%
50 x1 59.9950 Hz 9.9950 Hz 0.100 s 19.99%
59.94 x1 59.9950 Hz 0.0550 Hz 18.2 s 0.09%
60 x1 59.9950 Hz 0.0050 Hz 3.3 min 0.01%
120 x1 59.9950 Hz 60.0050 Hz 0.017 s 50.00%
144 x1 59.9950 Hz 84.0050 Hz 0.012 s 58.34%
165 x1 59.9950 Hz 105.0050 Hz 0.010 s 63.64%
240 x1 59.9950 Hz 180.0050 Hz 0.006 s 75.00%
best of these: 30 fps -> 6.6 min between slipped frames
Physical measurement
* 60.016893 Hz measured via DXGI IDXGIOutput::WaitForVBlank
wall-clock counting is approximate: 0.04% from the exact rational above.
that is why this tool reports the rational, not a measurement.
truehz can physically time vertical blanks as a sanity check. Both available ways of doing that were caught returning success from every single call while being completely wrong, in opposite directions:
| Probe | Result | Calls failed | What it actually did |
|---|---|---|---|
IDXGIOutput::WaitForVBlank |
911,300.12 Hz | 0 of 300 | did not wait at all |
DwmFlush |
3.87 Hz | 0 of 120 | waited on DWM's throttled composition pass, not the panel |
A zero failure count is not evidence that an API did its job. So the measurement is gated three separate ways, and all three must pass:
- Failure count - every call returned
S_OK. - Plausibility - the rate is inside 20-500 Hz.
- Stability - the run is split into four equal sub-windows and every window must report the same rate to within 2%.
The third gate is the one that matters, because it is the only one an average cannot fake. Observed on this machine:
! rejected DXGI IDXGIOutput::WaitForVBlank: 54.02 Hz -- unstable: sub-window
rates spread 28.5%, so it is not locked to vblank
54 Hz is perfectly plausible and had zero failures. It was still garbage, and only the spread revealed it.
Even when a probe passes all three gates, truehz prints how far it landed from the rational rather than presenting it as confirmation. Wall-clock counting is an approximation; the fraction is the answer.
The first implementation derived scaling from DESKTOPHORZRES / HORZRES. Across three consecutive runs on the same unchanged panel it reported 100%, then 250%, then 300%.
GetDeviceCaps(HORZRES) is virtualised by the calling process's DPI awareness. That ratio describes your process, not the display. It now pins the process to per-monitor-v2 awareness and reads the monitor's effective DPI instead, which is stable and independently confirmed:
GetScaleFactorForMonitor says 300%, tool says 300% (from 288 DPI)
This matters for capture: a recorder that is not DPI-aware sees this 3240 x 2160 panel as 1080 x 720 and records that.
truehz.ps1 # full report
truehz.ps1 -SkipMeasure # skip the vblank timing (fast, no GPU work)
truehz.ps1 -Fps 60,120,144 # advice for specific capture rates
truehz.ps1 -Json > hz.json # machine-readable
truehz.ps1 -FromJson hz.json # replay a report captured elsewhere
truehz.ps1 -Info # list every field in the JSON schema
truehz.ps1 -Quiet # headline only, one line per display
-Quiet is built for piping:
1 59.994970 Hz 453250000/7554800 \\.\DISPLAY1
-FromJson means you can collect on one machine and analyse on another, and it is the escape hatch if anything on your box refuses to enumerate.
That exact line in the example above used to produce one frame rate of 60120144.
powershell.exe -File hands every argument to the script as a string, so "60,120,144" arrives as a single token. Casting it with [double] succeeds - because .NET's default number style includes AllowThousands, and the commas are read as digit-group separators. No exception, no warning, just one plausible-looking wrong number.
The fix is to split on the separator first, then parse each piece with NumberStyles::Float, which excludes AllowThousands. Unusable values are named and skipped rather than silently coerced:
> truehz.ps1 -Fps 60,abc,-5,0,144
truehz: ignoring unusable -Fps value "abc"
truehz: ignoring unusable -Fps value "-5"
truehz: ignoring unusable -Fps value "0"
Those warnings go to stderr, and the dropped values are also recorded in the JSON as RejectedFps, so -Json stays parseable on stdout while still telling you what it threw away.
The negative control for this one is worth a note. The obvious mutation - swap NumberStyles::Float for ::Any - survived at first, because once the comma split is in place a comma can never reach the parser again. The two styles only diverge on a currency symbol, and under InvariantCulture that symbol is U+00A4, not $. The assertion that finally killed the mutant builds that character from its code point.
This is a plain .ps1. If your machine blocks scripts, the invocation above already handles it - -ExecutionPolicy Bypass is process-scoped and changes nothing permanent on your system. You do not need to run Set-ExecutionPolicy, and you should not.
Windows PowerShell 5.1 (in-box). Zero dependencies, nothing to install, no admin needed. The Windows API layer is embedded C# compiled by the in-box .NET compiler at startup.
truehz only reads. It does not need an undo because it never changes anything.
That is enforced, not asserted: the test suite greps the source for 26 mutating patterns (Set-ItemProperty, New-ItemProperty, Remove-Item, SetValue, reg add, Set-Display*, ChangeDisplaySettings, ...) and snapshots the display registry keys before and after a full run, requiring them byte-identical.
The only state it touches is its own process's DPI awareness flag, which dies with the process.
Three suites, all shipped in this repo so you can re-run them.
Synthetic fixtures with a different distinctive value in every field, so a tool that transposes two fields cannot pass. Two synthetic displays carry different vendors, product ids, connection types, resolutions, rationals, DPIs and derived sizes - 1280 x 720 vs 1024 x 576 share no digit.
Includes the decisive stability test: two probes with a bit-for-bit identical average rate of 59.9952 Hz, where only the one with uneven sub-windows is rejected. An average alone cannot separate them, which is exactly why the gate exists.
Real data from this machine, each claim checked against an independent implementation written with a different technique:
| Claim | Independent reference |
|---|---|
| record parsing | regex parser vs the tool's IndexOf/Substring |
| the refresh rational | DXGI GetDisplayModeList vs CCD QueryDisplayConfig - different DLL, different API family |
| signal timing | pixelClock / (totalCx x totalCy) reproduces the vsync frequency to 0.00 ppm |
| EDID vendor decode | root\wmi WmiMonitorID - a separate WMI namespace - confirms LGD |
| desktop scaling | GetScaleFactorForMonitor (different shcore entry point) confirms 300% |
| the cadence model | brute-force simulation over 1.2 million frames, agreeing to 0.000-0.399% |
Every one of those is followed by negative controls that replay the identical check against deliberately corrupted values - a byte-swap omitted, a percentage read as a fraction, a DPI off by one, a rate inverted, a slip counted in refreshes instead of frames. All 44 were rejected. A comparison that tolerates anything proves nothing.
Ground truth is also planted inside the real record stream: synthetic displays d90 and d91 with known values are appended to the genuine capture, proving the tool finds the right numbers while surrounded by real, irrelevant ones - and that none of the real ones leak into the result.
Every mutation is a deliberate one-line bug - an inverted comparison, a dropped clamp, a field read from the wrong offset, a gate removed - and the suite must catch it. Anchors are validated: a mutation whose target text does not exist tests nothing, so those are reported separately as invalid controls rather than counted as passes.
One mutant is excluded as provably equivalent, with the reason stated rather than hidden: computing the rate from the reduced fraction instead of the original produces a bit-identical double on every rational involved, verified through BitConverter.DoubleToInt64Bits. It is excluded from the score, not deleted from the suite.
mutation score : 69 of 69 killed
excluded no-ops : 0
equivalent : 1 (excluded, reason stated below)
invalid controls: 0
| Source | For |
|---|---|
QueryDisplayConfig (CCD) |
the exact refresh rational, pixel clock, raster totals, EDID ids, connector type |
DXGI IDXGIFactory1/IDXGIOutput |
independent mode list, and the vblank probe |
EnumDisplaySettings, GetDeviceCaps |
the rounded numbers, to show you the disagreement |
Win32_VideoController |
the truncated number, same reason |
GetDpiForMonitor |
real per-monitor scaling |
- obs-4k60-recorder - the 4K60 capture setup these tools support
- framecheck - finds dropped and duplicated frames in a recording
- gpucheck - whether the GPU was why frames dropped
- diskrate - whether the disk was why
- miccheck - why your audio drifted out of sync
MIT