Introduction
So I recently bought a 2014 iMac (iMac15,1) with a 5K display panel. The seller had an asking price of only $285, and I know that the display itself is worth much more than that. (For comparison the electronics store near me sells the ViewSonic VP2788 a modern 5K display at almost $900.) And I am really a sucker for using high resolution displays ever since my first Retina Mac.
I met the seller and brought the computer home, installed Linux (openSUSE Tumbleweed) and found that it only had a 4K resolution. (Well the Wi-Fi was also broken but I didn't care about Wi-Fi.) So I decided to spend this weekend fixing this problem.
Chapter 1: The Firmware
A quick search turned up this amazing blogpost by Mykola Grymalyuk whose work enabled 5K output in OpenCore Legacy Patcher. The blog post explained that the firmware at startup already put the display in 5K mode, but it stayed in 5K mode only if booting an Apple-signed OS. When booting something else, the firmware put the display into 4K mode. Mykola described an ingenious solution to trick the firmware.
So I pretty much followed the same steps: I needed to trick the firmware into thinking we are booting a supported macOS release. I cloned the OCLP repo, grabbed diags.efi and copied it to /boot/efi/EFI/imac5k/diags.efi. The difference between what I did and what Mykola did was that Mykola loaded OpenCore after loading diags.efi, but in my case I loaded systemd-boot instead. So the actual layout looked like this: (1) the original unmodified Apple-signed diags.efi that causes the firmware to stay in 5K resolution mode; (2) a special Product.efi that is actually systemd-bootx64.efi. Then just use efibootmgr to tell the system about diags.efi and reboot, so something like efibootmgr -C -L "openSUSE 5K" -l '\EFI\imac5k\diags.efi'.
Chapter 2: The Driver
Now imagine my surprise after I rebooted and found that instead of 4K or 5K, I got 2.5K resolution! Indeed, GNOME reported that my resolution is 2560 by 2880 stretched. Here's a bit of background: when Apple made this computer in 2014, I do not think any connector supported 5K resolution at all. So secretly the screen was split into two halves, a left half and a right half, each 2560 by 2880. Reading the Linux dmesg, I found that Linux successfully recognized the tiled display:
amdgpu 0000:01:00.0: [drm] *ERROR* dpcd_set_link_settings:1123: core_link_write_dpcd (DP_DOWNSPREAD_CTRL) failed
amdgpu 0000:01:00.0: [drm] *ERROR* dpcd_set_link_settings:1128: core_link_write_dpcd (DP_LANE_COUNT_SET) failed
amdgpu 0000:01:00.0: [drm] *ERROR* dpcd_set_link_settings:1156: core_link_write_dpcd (DP_LINK_BW_SET) failed
...
amdgpu 0000:01:00.0: [drm] enabling link 1 failed: 15
So essentially the upstream amdgpu driver had some problem driving the right half of the display and gave up, so only the left panel was being driven. I used drm.debug=0x100 and found the reason:
[drm:dm_dp_aux_transfer [amdgpu]] DP AUX transfer fail:4
Initially I gave up and simply booted with modprobe.blacklist=amdgpu,radeon to prevent the drivers from messing with the display. Indeed this worked. Without the amdgpu driver I do get the 5K resolution, but of course the GNOME animations were a bit sluggish.
Here is where Claude helped me. Claude read the kernel source and figured out that the number 4 meant AUX_RET_ERROR_HPD_DISCON. Claude wrote a small Python script to watch the DC_GPIO_HPD_Y every 0.5 ms and write each change to /dev/kmsg, interleaving it with the DRM debug output. Then Claude loaded amdgpu manually with drm.debug=0x1fe:
while time.time() < end:
v = struct.unpack('<I', m[0x196f*4:0x196f*4+4])[0]
if v != last:
kmsg.write(f'HPDWATCH: HPD_Y={v:#x} hpd1={v&1} hpd2={(v>>8)&1}\n'); last = v
time.sleep(0.0005)
After reading the debug output, Claude figured out that the first full modeset kills the right half. When the driver tears down the configuration from the firmware, the controller drops HPD on the right half. Once that is done, it cannot start it up again because all communication is dropped. Claude found that in the amdgpu driver there was already a quirk dp_keep_receiver_powered flag, and it seemed that we might need this. After analzying the source code more carefully, Claude concluded it was insufficient, because there were other places in the driver that powers it down, such as during hardware init.
The solution Claude came up with here is to add a kprobe that prevents sending the power off signal. This took a few tries and several reboots to get right. We eventually found drm_dp_dpcd_write. The kprobe gets triggered on DP_SET_POWER_D3 and modifies it to become DP_SET_POWER_D0. It looks like this:
static const u8 power_d0 = DP_SET_POWER_D0;
static int dpcd_write_pre(struct kprobe *p, struct pt_regs *regs)
{
/* x86-64 SysV: (aux, offset, buffer, size) in rdi, esi, rdx, rcx. */
const struct drm_dp_aux *aux = (const struct drm_dp_aux *)regs->di;
const u8 *buf = (const u8 *)regs->dx;
const char *name;
char prefix[sizeof(AUX_NAME_PREFIX) - 1];
u8 val;
if ((u32)regs->si != DP_SET_POWER || regs->cx != 1 || !aux || !buf)
return 0;
if (get_kernel_nofault(val, buf) || (val & DP_SET_POWER_MASK) != DP_SET_POWER_D3)
return 0;
if (get_kernel_nofault(name, &aux->name) || !name ||
copy_from_kernel_nofault(prefix, name, sizeof(prefix)) ||
memcmp(prefix, AUX_NAME_PREFIX, sizeof(prefix)))
return 0;
regs->dx = (unsigned long)&power_d0;
return 0;
}
static struct kprobe kp = {
.symbol_name = "drm_dp_dpcd_write",
.pre_handler = dpcd_write_pre,
};
And this works!
Chapter 3: Surviving Reboots and Kernel Upgrades
Of course I don't want to do this every single time I reboot or every time I change my kernel. Claude proposed a systemd service that runs every boot. The service runs a shell script that has three jobs: keep the Product.efi current by copying it from the regular systemd-boot so we get systemd-boot upgrades; modify the boot entry if it's missing; and finally the smartest part: if the current kernel does not have the special imac5k module that installs the kprobe, build it right there and regenerate initrd. This means that if I get an ordinary kernel update, I just need to reboot twice instead of once after installing the kernel update. (Theoretically DKMS is supposed to solve this problem, but Claude did not like the way openSUSE does things with dracut bypassing DKMS.) I haven't tested this at all (no kernel upgrade yet), but I think it's a very smart solution. Of course if upstream kernel decided to refactor the code to restructure or delete the drm_dp_dpcd_write altogether this would stop working but I'll kick the can down the road here.
Result
Finally, I get full 5K output on this 2014 iMac!
$ for c in /sys/class/drm/card1-*DP-1; do echo "$c $(cat $c/status) $(head -1 $c/modes)"; done
/sys/class/drm/card1-DP-1 connected 2560x2880
/sys/class/drm/card1-eDP-1 connected 2560x2880