Skip to content
appsmypassPublic

About

Windows says your display runs at 59 or 60 Hz. Mine actually runs at 59.994970 Hz - that is a duplicate frame every 3 minutes of recording. truehz reads the exact refresh rational Windows stores but never shows, and tells you which capture rate stops the stutter. Zero dependencies, read-only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

truehz

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

Why you care

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.


Real output, one run, this machine

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.

Two Windows APIs that return S_OK and still lie

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:

  1. Failure count - every call returned S_OK.
  2. Plausibility - the rate is inside 20-500 Hz.
  3. 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.


desktop scaling - a number that was wrong in this tool first

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.


Usage

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.

-Fps 60,120,144 - the second number this tool got wrong

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.

Execution policy

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.

Requirements

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.


Read-only

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.


Test evidence

Three suites, all shipped in this repo so you can re-run them.

selftest.ps1 - 202 assertions, 0 failures

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.

realcheck.ps1 - 107 assertions, 0 failures, 44 of 44 negative controls killed

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.

mutate.ps1 - 69 of 69 scored mutations killed, 0 invalid controls, 1 excluded

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

What it reads

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

See also

  • 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

License

MIT

About

Windows says your display runs at 59 or 60 Hz. Mine actually runs at 59.994970 Hz - that is a duplicate frame every 3 minutes of recording. truehz reads the exact refresh rational Windows stores but never shows, and tells you which capture rate stops the stutter. Zero dependencies, read-only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages