HDMI can't do it: 4K at 100Hz on Linux, and the workarounds that outlived the problem

My monitor does 100Hz and my GPU can drive it, but no open-source driver is permitted to implement HDMI 2.1 FRL. Switching to DisplayPort fixed the refresh rate, fixed the colour range, and left me with a contrast setting I had raised to compensate for a problem that no longer existed.

Connected over HDMI, KDE offered me 60Hz and did not list 100Hz at all — not greyed out, not failing to apply. Absent.

The hardware was never the problem. Neither was the driver, exactly. The blocker is a licence agreement.

Fixing it meant changing cables, which fixed a second thing I hadn't realised was broken, which then exposed a third: a monitor setting I'd cranked months earlier to compensate for the problem that had just gone away. That last part is the one worth reading. A Dell P3225QE and a System76 Darter Pro with Intel Arc 140T graphics, on Arch with KDE Plasma on Wayland.

The number that doesn't fit

Three facts sit in the monitor's EDID simultaneously:

VIC 117: 3840x2160 @100Hz — requires a 1188 MHz pixel clock
Maximum TMDS Character Rate: 600 MHz
Max Fixed Rate Link: 12 Gbps × 4 lanes

The first line is the monitor advertising 100Hz. The second is the ceiling of TMDS, the older HDMI signalling scheme — 600 MHz, about half of what the mode needs. The third says the monitor fully supports FRL (Fixed Rate Link), the HDMI 2.1 signalling that exists precisely to get past that ceiling.

So: the monitor supports FRL, the Arc 140T supports FRL, and the mode requires FRL. The kernel does the arithmetic, sees 1188 > 600, and filters the mode out before it ever reaches the compositor. That's why it isn't in the list — nothing failed, the mode was never offered.

Why the driver won't do it

Open-source graphics drivers are not permitted to implement FRL. The HDMI 2.1 specification is controlled by the HDMI Forum, and its terms forbid publishing an FRL implementation in open source. In 2023 AMD formally asked to implement HDMI 2.1 in its open driver and was explicitly refused. Intel's i915/xe are bound by the same terms.

The consequence is blunt: on Linux, HDMI output from any open-source driver is pinned to TMDS at 600 MHz, so 4K tops out at 60Hz. DSC doesn't rescue you either — display stream compression rides on FRL, so it's downstream of the same blocker.

The irony is that the only way to get HDMI 4K@120 on Linux is NVIDIA's *proprietary* driver, which doesn't violate the terms because it isn't open. That escape hatch doesn't exist here: Intel ships no proprietary graphics driver on Linux at all — i915 and xe are GPL and in mainline, and userspace is Mesa.

>

"Install AMDGPU-PRO" is the same dead end, and it's a widespread misconception. AMDGPU-PRO only swaps the userspace OpenGL/Vulkan stack; the kernel module is still the open-source amdgpu, and HDMI output is kernel KMS work.

The fix is a different cable

DisplayPort is not covered by any of this. I moved to the laptop's Thunderbolt port, into the monitor's USB-C upstream port — DP 1.4 Alt Mode plus 90W power delivery.

One cable now carries 100Hz, charges the laptop, and provides the monitor's three USB-A ports and its gigabit ethernet. Both the USB-C and DP cables were in the box.

There's bandwidth to spare. The DisplayID block advertises a cheaper timing than the HDMI one — 3840x2160@100Hz at 906 MHz with reduced blanking. 4K@100 in 8-bit RGB needs roughly 19.9 Gbps; DP 1.4 HBR3 at full width provides 25.92 Gbps. No compression required.

One thing to know: once connected, one of card1-DP-1/2/3 goes connected, but KDE will not switch to 100Hz on its own. You have to select it in Display and Monitor.

kscreen-doctor -o | grep -E "DP-1|3840x2160@100"
# the starred mode is current:  2:3840x2160@100.00*

If USB-C still only gives you 60Hz, the upstream link is splitting itself between DP and a 5Gbps USB hub, leaving DP just 2 lanes (12.96 Gbps effective) — enough only with DSC, which Intel does support in the open driver, so it usually works anyway. If it doesn't: set the monitor's OSD USB-C priority to "High Resolution" (the hub drops to USB 2.0 and ethernet slows), or use a USB-C→DP cable to get all 4 lanes (losing charging and the hub).

Then the picture looked over-exposed

Whites were painfully white. My first instinct was that switching cables had broken the colour pipeline, and my first two hypotheses were both wrong in instructive ways.

Hypothesis 1: RGB quantisation range mismatch. This is the classic display bug — the GPU sends Full range, the monitor reads it as Limited (or vice versa), and highlights clip. The symptom fits perfectly. It was also wrong:

connector[271]: DP-1          # in use
    max_requested_bpc=12
    colorspace=Default
    (no broadcast_rgb)

connector[293]: HDMI-A-1      # unplugged
    broadcast_rgb=Automatic
    is_limited_range=n

The `broadcast_rgb` DRM property only exists on HDMI connectors. DisplayPort doesn't have it. That's how the Intel driver implements it, because the DisplayPort protocol specifies quantisation range itself — Full, for PC content — so there is no knob to expose.

Worth flagging, because it cost me time: kscreen-doctor output.DP-1.rgbrange.limited is accepted and does nothing. No error, no warning, no effect. Check the DRM state, not the command's exit code.

Hypothesis 2: colour profile source. Partially relevant. Switching to DP changed Color profile source from sRGB to EDID, so KWin began colour-managing against the chromaticity coordinates in the monitor's EDID. I set it back:

kscreen-doctor output.DP-1.colorProfileSource.sRGB

EDID chromaticity is a vendor's nominal claim, not a measurement. This is a 99% sRGB panel that's already close to sRGB by construction, so mapping through EDID numbers can introduce error rather than remove it.

The actual cause was in the monitor, not the computer. Reading its registers over DDC/CI:

$ sudo ddcutil getvcp 10 12 14
Brightness   (0x10) = 90
Contrast     (0x12) = 90
Color preset (0x14) = 6500 K

Brightness at 90%. On a 350-nit panel that's about 315 nits, against a recommended 120–160 for indoor office work. Roughly double.

And the contrast at 90 is the interesting one, because I put it there — to compensate for a problem that had just been fixed by the cable.

Back on HDMI, the Intel driver's broadcast_rgb=Automatic policy is "use Limited range for CTA-defined CE timings," and 4K@60 is exactly CTA's VIC 97. So the GPU had been sending Limited (16–235) while the monitor interpreted it as Full — blacks lifted, the whole image washed out and flat. I compensated by pushing contrast up. On DisplayPort, Full range passes through and both ends finally agree; that compensation instantly became pure overshoot.

This is the general shape of the thing: when you fix a root cause, go back and remove the workarounds you built for it. A setting that was necessary against a broken picture is a defect against a correct one. Anything you tuned while the underlying problem was live has to be re-examined once it isn't — it was calibrated against a foundation that no longer exists.

Which knob to actually turn

The two controls are not equivalent, and most people reach for the wrong one:

Control

What it does

Cost

Brightness (0x10)

Changes the physical backlight intensity

Lossless - greyscale relationships are untouched

Contrast (0x12)

Changes digital signal gain

Lossy - moving off the calibration point compresses or clips greyscale

So: set contrast to the factory calibration point and never touch it again — 75 on this Dell. Leave the colour temperature at 6500K, which is already the most accurate setting. Brightness is the only knob you should be adjusting, because it's the only one that costs nothing.

sudo ddcutil setvcp 12 75     # contrast back to the calibration point
sudo ddcutil setvcp 10 55     # brightness to suit the room

For a 350-nit panel:

Environment

Brightness

Approx.

Bright office / next to a window

50-65

175-230 nits

Normal indoor lighting

35-45

120-160 nits

Evening / dim

20-30

70-105 nits

The most practical calibration method needs no instrument: hold a sheet of white paper next to the screen under the same light, and adjust brightness until the white areas of the screen match the paper. Then bring the laptop's own display to roughly the same level — a large brightness mismatch between two screens is worse for your eyes than either one being slightly too bright, because they re-adapt every time you look across.

To drop the sudo, add yourself to the i2c group and log back in:

sudo usermod -aG i2c $USER

KDE reports DDC/CI: allowed and Brightness control: supported for this monitor, so the system tray brightness slider drives the external panel too. Both paths write the same register — don't fight them against each other.

Was 100Hz actually what made it feel better?

Mostly, yes — and the arithmetic is worth seeing, because it puts every software tweak in perspective.

First, what *isn't* the problem: the hardware cursor plane works correctly here (cursor B bound to pipe B), meaning pointer position is written straight to display controller registers and bypasses the compositor entirely. That path is identical to Windows and macOS. No gap.

The gap is in two other places. One is the ability to bypass compositing at all. Each pipe on this hardware has five hardware planes, and a normal desktop uses two:

plane 1B  crtc=pipe B  fb=309   # in use
plane 2B  crtc=(null)           # idle
plane 3B  crtc=(null)           # idle
plane 4B  crtc=(null)           # idle
plane 5B  crtc=pipe B  fb=318   # in use

Windows fills them, via MPO (Multi-Plane Overlay): video and game windows get their own hardware planes and the display controller blends them during scanout, with the GPU not composing at all. Independent Flip goes further, letting a qualifying window flip straight to the screen and skip DWM entirely — worth a whole frame. KWin has direct scanout, but its trigger conditions are much stricter, in practice covering fullscreen applications. An ordinary multi-window desktop composites through the GPU every frame.

The other is simply the refresh rate, and it dominates. Compositor latency is counted in *frames* — typically two or three — so the frame interval scales everything:

Refresh rate

Frame interval

2-3 frame compositing latency

60Hz

16.7ms

33-50ms

100Hz

10ms

20-30ms

120Hz

8.3ms

17-25ms

Going from 60Hz to 100Hz bought more than every software adjustment combined. If you want to squeeze a little further afterwards, KWin's latency policy defaults to something conservative that reserves headroom against dropped frames:

kwriteconfig6 --file kwinrc --group Compositing --key LatencyPolicy Low
qdbus6 org.kde.KWin /KWin reconfigure

# revert
kwriteconfig6 --file kwinrc --group Compositing --key LatencyPolicy --delete
qdbus6 org.kde.KWin /KWin reconfigure

ExtremelyLow is more aggressive and drops frames at 4K. Separately, the Blur effect is genuinely expensive at this resolution — if you don't care about frosted glass, turning it off in Desktop Effects buys back a measurable slice of every frame.

Diagnostics

# Display
kscreen-doctor -o                          # modes, refresh rates, scaling, colour
edid-decode /sys/class/drm/card1-DP-1/edid # VIC list, FRL capability, chromaticity
sudo cat /sys/kernel/debug/dri/1/state     # DRM atomic state: planes, connector props
for p in /sys/class/drm/card1-*/; do echo "$(basename $p): $(cat $p/status)"; done

# Monitor OSD over DDC/CI
sudo ddcutil detect
sudo ddcutil getvcp 10 12 14               # brightness / contrast / colour temp
sudo ddcutil capabilities                  # every VCP feature this monitor exposes

No comments yet