Skip to content

Instantly share code, notes, and snippets.

@ryanlewis
Created June 9, 2026 21:16
Show Gist options
  • Select an option

  • Save ryanlewis/925818547a204a0658dbdaa36e858fe4 to your computer and use it in GitHub Desktop.

Select an option

Save ryanlewis/925818547a204a0658dbdaa36e858fe4 to your computer and use it in GitHub Desktop.
Add a real 4K@120 mode to an LG UltraGear (DP1.4+DSC) by patching its EDID

Adding a real 4K @ 120 Hz mode to an LG UltraGear (DP 1.4 + DSC) via EDID patch

⚠️ At your own risk. Editing an EDID can leave you with a black or green screen. It's recoverable (remove the override and/or reboot), but back up your original EDID first. No warranty, you break it you keep both pieces, etc.

The problem

LG UltraGear 4K monitors that reach 240 Hz over DisplayPort 1.4 with DSC advertise these 4K refresh rates in their DisplayID block:

3840x2160 @ 240 Hz
3840x2160 @ 144 Hz
3840x2160 @  95 Hz

Notice what's missing: 120 Hz. And 120 is the one you actually want for mixed video, because it's the only rate here that divides the common frame rates cleanly:

content ÷ 120 ÷ 144
24 fps 5 ✅ 6 ✅
30 fps 4 ✅ 4.8 ❌
60 fps 2 ✅ 2.4 ❌

144 Hz judders on 30 and 60 fps; 95 Hz is useless for everything. So the goal is simple: replace the dead 95 Hz timing with 120 Hz.

Why you can't just "add" it the easy way

4K @ 120 Hz needs a pixel clock around 1.1 GHz. The legacy 18-byte Detailed Timing Descriptor used in the base EDID block can only encode a pixel clock up to 655.35 MHz, so 4K@120 physically cannot live there. That's why these high-rate modes sit in the DisplayID extension block instead (it uses a 3-byte pixel-clock field). The patch therefore edits the DisplayID block directly.

Step 1 — Dump your current EDID

macOS (BetterDisplay): select the monitor → find the EDID section → Export EDID to a .bin. Keep this file — it's your backup / revert.

Linux: read it straight from the kernel (pick the right connector):

ls /sys/class/drm/                       # find your card*-DP-* connector
cat /sys/class/drm/card1-DP-1/edid > monitor-original.bin

Step 2 — Patch it

Grab patch_lg_edid.py from this gist. It auto-locates the ~95 Hz 4K timing in the DisplayID block, rewrites it to exactly 120 Hz (keeping the same blanking, just a new pixel clock), and recomputes both the DisplayID and EDID-extension checksums.

python3 patch_lg_edid.py monitor-original.bin monitor-4k120.bin

Example output:

DisplayID Type-I timings found:
  3840x2160 @ 240.08 Hz
  3840x2160 @ 144.05 Hz
  3840x2160 @  95.03 Hz  <- replacing with 120 Hz
Wrote 4K@120 (pclk 1084.32 MHz) over the 95 Hz slot.
After patch:
  3840x2160 @ 240.08 Hz
  3840x2160 @ 144.05 Hz
  3840x2160 @ 120.00 Hz

(You can also pass --b64 'AP///...' to work from a base64 string and print one back.)

Step 3 — Apply it

macOS (BetterDisplay): select the monitor → EDID section → Override / Import EDID → choose monitor-4k120.bin. It re-handshakes the display (may blink). If it doesn't take, toggle the override off/on or unplug/replug.

Linux (kernel EDID firmware override):

sudo cp monitor-4k120.bin /usr/lib/firmware/edid/lg-4k120.bin
# add a kernel boot parameter (per-connector form shown):
#   drm.edid_firmware=DP-1:edid/lg-4k120.bin
# rebuild initramfs if your distro needs the firmware baked in, then reboot.

(Linux override method adapted from kj800x's EDID gist.)

Step 4 — Pick the mode (and the DSC gotcha)

Select 3840×2160 @ 120 Hz.

One important nuance on macOS: the OS only turns DSC on when a mode won't fit uncompressed. 4K@120 at 8-bit just barely fits DP 1.4 without compression — so macOS will run it DSC-off, consuming the whole link. That's fine for a single monitor. But if you drive a second display off the same DisplayPort link, the uncompressed 4K@120 hogs the bandwidth and starves the other monitor. To force DSC and free that bandwidth, enable HDR for the monitor (10-bit pushes the mode past the uncompressed limit → macOS engages DSC). Then both displays fit.

Step 5 — Verify / revert

  • Verify: the refresh list now shows 60 / 120 / 144 / 240 with the 95 Hz gone, and 120 Hz applies cleanly.
  • Revert: remove the override (macOS) or drop the boot parameter (Linux), or re-import your monitor-original.bin. A reboot also clears a bad override.

How the patch works (for the curious)

DisplayID Type-I Detailed Timing pixel-clock encoding (3 bytes, little-endian):

pclk_khz = 10 × (1 + byte0 + byte1·256 + byte2·65536)
refresh  = pclk_khz × 1000 / (htotal × vtotal)

To turn the 95 Hz timing into 120 Hz, keep its htotal/vtotal and set:

pclk_khz = htotal × vtotal × 120 / 1000

then re-encode those 3 bytes and fix the two checksums (DisplayID section checksum, then the EDID extension-block checksum so each block sums to 0 mod 256). That's all the script does.


Tested on an LG UltraGear 31.5" 4K 240 Hz panel over DP 1.4 (DSC) on macOS. Should apply to any LG UltraGear that exposes the 4K 240/144/95 DisplayID timing set in DSC mode. YMMV on other models — read the script's printout before trusting it.

#!/usr/bin/env python3
"""
patch_lg_edid.py — give an LG UltraGear (DP 1.4 + DSC) a real 4K@120 mode.
THE SCENARIO
------------
When these monitors run in DisplayPort 1.4 *DSC* mode, their DisplayID block
advertises 4K @ 240 / 144 / 95 Hz — but NOT 120 Hz. 120 is the only one of
those that divides 24/30/60 fps cleanly (144 judders on 30 and 60), so it's
the rate you actually want for mixed video. 4K@120's pixel clock (~1.1 GHz)
is too high for a legacy 18-byte detailed-timing descriptor (capped ~655 MHz),
so it can only live in the DisplayID block.
This script finds the largely-useless 4K@95 timing in the DisplayID Type-I
timing block and rewrites it as 4K@120 (keeping the same blanking, just a new
pixel clock), then fixes both checksums.
USAGE
-----
python3 patch_lg_edid.py input.bin output.bin
python3 patch_lg_edid.py --b64 'AP///...' # prints new base64
AT YOUR OWN RISK. A malformed EDID can give a black/green screen. It's
recoverable (remove the override / reboot), but back up your original first.
"""
import sys, base64
def load(argv):
if argv[0] == "--b64":
return bytearray(base64.b64decode(argv[1])), None
with open(argv[0], "rb") as f:
return bytearray(f.read()), (argv[1] if len(argv) > 1 else None)
def find_displayid(data):
# EDID = 128-byte base block + N extension blocks. DisplayID ext starts 0x70.
for off in range(128, len(data), 128):
if data[off] == 0x70:
return off
raise SystemExit("No DisplayID extension block (0x70) found.")
def timings(block):
"""Yield (abs_offset_in_block, refresh, hact, vact) for each Type-I timing."""
sec_size = block[2]
i = 5
while i < 5 + sec_size:
tag = block[i]
if tag == 0:
break
blen = block[i + 2]
if tag == 0x03: # Type I Detailed Timing block; 20 bytes per timing
for k in range(i + 3, i + 3 + blen, 20):
t = block[k:k + 20]
if len(t) < 20:
break
pclk_khz = ((t[0] | (t[1] << 8) | (t[2] << 16)) + 1) * 10
hact = (t[4] | (t[5] << 8)) + 1
hbl = (t[6] | (t[7] << 8)) + 1
vact = (t[12] | (t[13] << 8)) + 1
vbl = (t[14] | (t[15] << 8)) + 1
htot, vtot = hact + hbl, vact + vbl
refresh = pclk_khz * 1000 / (htot * vtot)
yield k, refresh, hact, vact, htot, vtot
i += 3 + blen
def main():
if not sys.argv[1:] or sys.argv[1] in ("-h", "--help"):
print(__doc__); return
data, outpath = load(sys.argv[1:])
did = find_displayid(data)
block = data[did:did + 128]
# locate the ~95 Hz 4K timing
target = None
print("DisplayID Type-I timings found:")
for k, refresh, hact, vact, htot, vtot in timings(block):
flag = ""
if hact == 3840 and 90 <= refresh <= 100:
target = (k, htot, vtot); flag = " <- replacing with 120 Hz"
print(f" {hact}x{vact} @ {refresh:6.2f} Hz{flag}")
if not target:
raise SystemExit("\nNo ~95 Hz 4K timing found - nothing to replace.")
k, htot, vtot = target
abs_k = did + k
# new pixel clock so refresh == 120, keeping this timing's own blanking
new_pclk_khz = round(htot * vtot * 120 / 1000)
raw = new_pclk_khz // 10 - 1
data[abs_k + 0] = raw & 0xFF
data[abs_k + 1] = (raw >> 8) & 0xFF
data[abs_k + 2] = (raw >> 16) & 0xFF
print(f"\nWrote 4K@120 (pclk {new_pclk_khz/1000:.2f} MHz) over the 95 Hz slot.")
# fix DisplayID section checksum, then EDID extension-block checksum
sec_cks_off = did + 5 + data[did + 2]
data[sec_cks_off] = (-sum(data[did + 1:sec_cks_off])) & 0xFF
blk_cks_off = did + 127
data[blk_cks_off] = (-sum(data[did:blk_cks_off])) & 0xFF
# sanity: re-read
block = data[did:did + 128]
print("After patch:")
for _, refresh, hact, vact, *_ in timings(block):
print(f" {hact}x{vact} @ {refresh:6.2f} Hz")
assert sum(data[did:did + 128]) % 256 == 0, "ext checksum bad"
if outpath:
with open(outpath, "wb") as f:
f.write(bytes(data))
print(f"\nWrote {outpath}")
else:
print("\nNew base64 EDID:\n" + base64.b64encode(bytes(data)).decode())
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment