diff --git a/AGENTS.md b/AGENTS.md index 40a18ce..683332b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine `steam-set-session plasma.desktop` plus the plasmalogin sync bridge when it exists, at every boot. CachyOS's `cachyos-gamescope-autologin` still sets gamescope during each desktop session; the unit corrects it at boot. -- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`), not an on/off +- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`) and a sub-option of `machine`, not an on/off component: never preselected (not even on a first run), never re-applied by `a`, not listed in the state overview, and `bios_status` is always off. Only selectable when Valve's version differs from the installed one @@ -171,6 +171,15 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine The start script closes its window after a 10-second countdown on success and waits for Enter after an error; it uses `pipefail`, or a failed download would run an empty script and count as success. +- HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): a sub-option of + `machine` (like `kpin` and `bios`), only on Fremont with the pinned kernel + (stays visible while on); unticking `kpin` unticks it. It needs someone at the + screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only + (glob it under sudo), and `edid_override` takes exactly `reset` with no + newline. Build the EDID from the DDC read, not from sysfs: a live override + replaces the kernel's copy. The kernel command-line value holds a path, so + `sed` uses `|` as its delimiter. - `Relogin=true` means a gamescope that fails to start is relaunched in a tight loop; keep that in mind when changing session handling. diff --git a/CHANGELOG.md b/CHANGELOG.md index c4af0d8..4161285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes, per version and per commit. Versions follow `steamify.sh`. Versions before 0.7.0 were numbered afterwards, one per merged pull request. +## 1.2.0 - 2026-09-25 + +- **feat: HDMI refresh boost (Steam Machine, pinned kernel)** + - The pinned 7.1.6 kernel doesn't read the extra EDID block where monitors + list their fast modes, and does no HDMI 2.1, so HDMI displays often stay + at 60 Hz. The new menu item reads the display's EDID over DDC, + calculates the highest rate that fits HDMI 2.0 at the desktop resolution + (rounded down to ten, plus the hundred below as a safe option), tests + each step live with a confirmation, and makes the confirmed ones + permanent through `drm.edid_firmware` (Limine, sdboot-manage or GRUB). +- **feat: HDMI refresh boost and Update BIOS under Steam Machine support** + - Both are sub-options now, shown only while Steam Machine support is + ticked. Unticking it unticks them; unticking the kernel pin unticks the + HDMI refresh boost. + ## 1.1.4 - 2026-09-25 - **fix: No lock screen in single user mode before a restart** diff --git a/README.md b/README.md index a88af9a..6180589 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,15 @@ What Steamify can do on a Valve Steam Machine: [release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1), checked against CachyOS's signature ([details](TECHNICAL.md#kernel-pin-steam-machine)). -- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked - by default, at your own risk, and only after two warnings - ([details](TECHNICAL.md#bios-updates-steam-machine)). + - **HDMI refresh boost** - only with the pinned kernel, never ticked by + default: gets the highest refresh rate your HDMI display runs at the + desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor). + Each step is shown and you confirm it; no answer within 15 s switches + back. For more (e.g. 175 Hz), use DisplayPort + ([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)). + - **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked + by default, at your own risk, and only after two warnings + ([details](TECHNICAL.md#bios-updates-steam-machine)). ## Requirements diff --git a/TECHNICAL.md b/TECHNICAL.md index 1f52744..ce1b8bb 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -229,6 +229,48 @@ into the kernel directory yourself. Unticking it removes the pin and runs `sudo pacman -Syu`, which brings the kernel back to CachyOS's current version; the files stay for next time. +## HDMI refresh boost (Steam Machine) + +With the pinned kernel (7.1.6), HDMI displays often stay at 60 Hz. Two +reasons: + +- Monitors list their fast modes in an extra EDID block, announced by the + HDMI Forum EEODB data block. 7.1.6 only reads the first extension block, + so it never sees them. Newer kernels do. +- Their fastest modes need HDMI 2.1 (FRL). 7.1.6's amdgpu only does HDMI + 2.0 (TMDS, at most 600 MHz), but a mode with the display's own shortest + blanking at a slightly lower rate often fits. + +The menu item (only on a Steam Machine with the pinned kernel, never +ticked by default, run from the desktop in Konsole): + +1. Takes the desktop resolution from KDE (`kscreen-doctor -j`) and reads the + display's complete EDID over DDC (`i2ctransfer`, segment pointer 0x30). + A live EDID left by an earlier test is cleared first. +2. Calculates the highest rate that fits: the display's TMDS limit (HDMI + Forum VSDB, capped at amdgpu's 600 MHz), its shortest blanking at that + resolution and its maximum refresh (range limits, VRR maximum). Steps: + that rate rounded down to ten, and the hundred below it as a safe option. + Rates the display already lists, or that aren't faster than what works + now, are left out. +3. Builds the EDID: all of the display's blocks, the block count and EEODB + fixed, plus a DisplayID block with the steps. +4. Loads it live (debugfs `edid_override`, `trigger_hotplug`) and switches + to each step, lowest first. Each one needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for tests); anything else switches back + and stops. +5. Makes it permanent with only the confirmed steps: + `/usr/lib/firmware/edid/steamify-.bin`, in the initramfs via + `/etc/mkinitcpio.conf.d/90-steamify-edid.conf` (amdgpu loads from there), + and `drm.edid_firmware=:edid/steamify-.bin` on the + kernel command line: `/etc/default/limine` (`limine-mkinitcpio`), + `/etc/sdboot-manage.conf` or `/etc/default/grub`. + +The display's manufacturer, model and serial are stored; re-applying with +another display connected tests that one instead. Turning it off removes +the files and the parameter and rebuilds. Untick it before removing the +kernel pin: newer kernels read the EDID themselves and can do HDMI 2.1. + ## BIOS updates (Steam Machine) The **Update BIOS** item is only shown on a Steam Machine and is never ticked @@ -298,6 +340,7 @@ immediately, which can turn into a loop - see | `lib/bios.sh` | Update BIOS (Steam Machine, opt-in): current/newest version, double confirmation, fwupd | | `lib/cec.sh` | HDMI-CEC: Valve's `cecd` and friends from its `holo` repository | | `lib/steam-machine.sh` | Steam Machine support: LED driver, LED access, steamos-manager; kernel pin | +| `lib/hdmi-refresh.sh` | HDMI refresh boost (Steam Machine, pinned kernel): EDID over DDC, calculated steps, live test, `drm.edid_firmware` | | `.github/tools/bundle.sh` | Builds the single-file version (`dist/steamify.sh`) | | `.github/workflows/bundle.yml` | Builds and checks it on every push; publishes it on `main` | diff --git a/lib/hdmi-refresh.sh b/lib/hdmi-refresh.sh new file mode 100644 index 0000000..e85fcfc --- /dev/null +++ b/lib/hdmi-refresh.sh @@ -0,0 +1,438 @@ +#!/bin/bash +# "HDMI refresh boost" menu item, Steam Machine (Fremont) only: finds the +# highest refresh rate the display runs at the desktop resolution over HDMI, +# tests it live and makes it permanent with an EDID override +# (drm.edid_firmware). Two things keep displays below what they can do: +# - Monitors put their fast modes in an extra EDID block (HDMI Forum EEODB); +# the pinned kernel (7.1.6) only reads the first extension. Newer kernels +# do, and do HDMI 2.1, so it's only offered with the pinned kernel. +# - Their fastest modes need HDMI 2.1 (FRL), which amdgpu doesn't do on +# every kernel; a mode with the display's shortest blanking at a slightly +# lower rate often still fits HDMI 2.0's TMDS limit (600 MHz). +# What a display claims isn't what it accepts: every step is shown and must +# be confirmed, and no answer within 15 s switches back (the screen may be +# black). Sourced by steamify.sh; not meant to be run on its own. + +HDMI_FW_DIR=/usr/lib/firmware/edid +HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf +# No answer within this time switches back; longer for scripted tests. +HDMI_CONFIRM_SECONDS="${WIZARD_HDMI_CONFIRM_SECONDS:-15}" +# amdgpu's HDMI 2.0 limit; the display's own limit (from its EDID) may be lower. +HDMI_TMDS_MAX_KHZ=600000 + +hdmi_connectors() { + # Connected HDMI outputs, as "-" (e.g. card1-HDMI-A-1). + local d + for d in /sys/class/drm/card*-HDMI-A-*; do + [[ "$(cat "$d/status" 2>/dev/null)" == connected ]] && basename "$d" + done +} + +hdmi_boot_file() { + # The file holding the kernel command line, per boot loader. + if [[ -f /etc/default/limine ]]; then echo /etc/default/limine + elif [[ -f /etc/sdboot-manage.conf ]] && command -v sdboot-manage >/dev/null; then echo /etc/sdboot-manage.conf + elif [[ -f /etc/default/grub ]]; then echo /etc/default/grub + fi +} + +hdmi_cmdline_param() { + # The drm.edid_firmware= value we set, or nothing. + local f; f="$(hdmi_boot_file)" + [[ -n "$f" ]] && grep -oE 'drm\.edid_firmware=[^ "]*steamify-[^ "]*' "$f" 2>/dev/null | head -n 1 +} + +hdmi_available() { + # Stays available while on, so it can be turned off after the pin is gone. + detect_valve_fremont || return 1 + hdmi_status || { pinned_kernel_installed && [[ -n "$(hdmi_connectors)" && -n "$(hdmi_boot_file)" ]]; } +} + +hdmi_status() { [[ -n "$(hdmi_cmdline_param)" ]] && compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null; } + +hdmi_edid_tool() { + # hdmi_edid_tool ...: EDID parsing and building (python, as + # it's byte work). Commands: + # blocks number of blocks the display says it has + # id vendor/product/serial, to recognise it + # plan " ..." for w x h + # build ... full EDID plus a DisplayID block + # with those rates at the shortest blanking + python3 - "$HDMI_TMDS_MAX_KHZ" "$@" <<'PY' +import sys + +src_khz = int(sys.argv[1]); cmd = sys.argv[2]; args = sys.argv[3:] +def load(p): return bytearray(open(p, 'rb').read()) +def le(b): return int.from_bytes(b, 'little') + +def cta_blocks(e): + for i in range(128, len(e), 128): + if e[i] == 0x02: yield i + +def data_blocks(e, i): + # CTA data block collection: (tag, extended tag, offset, length) + end, p = e[i + 2], i + 4 + while p < i + end and p < i + 127: + tag, n = e[p] >> 5, e[p] & 0x1f + yield tag, (e[p + 1] if tag == 7 and n else None), p, n + p += n + 1 + +def eeodb(e): + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag == 7 and ext == 0x78: return p + 2 + return None + +def nblocks(e): + p = eeodb(e) + return 1 + max(e[126], e[p] if p and p < len(e) else 0) + +def timings(e): + # (hz, clk_khz, ha, hb, hf, hs, hpos, va, vb, vf, vs, vpos) + out = [] + def dtd(b): + clk = le(b[0:2]) * 10 + if not clk: return + ha = b[2] | (b[4] >> 4) << 8; hb = b[3] | (b[4] & 15) << 8 + va = b[5] | (b[7] >> 4) << 8; vb = b[6] | (b[7] & 15) << 8 + hf = b[8] | (b[11] >> 6 & 3) << 8; hs = b[9] | (b[11] >> 4 & 3) << 8 + vf = (b[10] >> 4) | (b[11] >> 2 & 3) << 4; vs = (b[10] & 15) | (b[11] & 3) << 4 + if b[17] & 0x80: return # interlaced + out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, hf, hs, b[17] >> 1 & 1, + va, vb, vf, vs, b[17] >> 2 & 1)) + for o in range(54, 126, 18): dtd(e[o:o + 18]) + for i in cta_blocks(e): + d = e[i + 2] + if d >= 4: + for o in range(i + d, i + 127 - 17, 18): dtd(e[o:o + 18]) + for i in range(128, len(e), 128): + if e[i] != 0x70: continue + p, end = i + 5, i + 5 + e[i + 2] + while p + 3 <= end: + tag, n = e[p], e[p + 2] + if tag == 0 and n == 0: break + if tag == 0x03: + for o in range(p + 3, p + 3 + n - 19, 20): + b = e[o:o + 20] + clk = (le(b[0:3]) + 1) * 10 + hf, vf = le(b[8:10]), le(b[16:18]) + ha, hb, va, vb = le(b[4:6]) + 1, le(b[6:8]) + 1, le(b[12:14]) + 1, le(b[14:16]) + 1 + out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, (hf & 0x7fff) + 1, + le(b[10:12]) + 1, hf >> 15, va, vb, (vf & 0x7fff) + 1, le(b[18:20]) + 1, vf >> 15)) + p += 3 + n + return out + +def tmds_khz(e): + # The display's own TMDS limit: HDMI Forum VSDB, else the HDMI 1.4 VSDB. + hf = h14 = 0 + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag != 3 or n < 3: continue + oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1] + if oui == 0xC45DD8 and n >= 5: hf = e[p + 5] * 5000 + if oui == 0x000C03 and n >= 7: h14 = e[p + 7] * 5000 + lim = hf or h14 or 165000 + return min(lim, src_khz) + +def max_vrefresh(e): + # Range limits descriptor, the HDMI Forum / AMD VRR maximum. + best = 0 + for o in range(54, 126, 18): + b = e[o:o + 18] + if b[0:3] == b'\0\0\0' and b[3] == 0xfd: + best = max(best, b[6] + (255 if b[4] & 2 else 0)) + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag != 3 or n < 3: continue + oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1] + if oui == 0xC45DD8 and n >= 10: best = max(best, (e[p + 9] & 0xc0) << 2 | e[p + 10]) + if oui == 0x00001A and n >= 7: best = max(best, e[p + 7]) + return best + +def shortest(e, w, h): + ts = [t for t in timings(e) if t[2] == w and t[7] == h] + return min(ts, key=lambda t: (t[2] + t[3]) * (t[7] + t[8])) if ts else None + +if cmd == 'blocks': + print(nblocks(load(args[0]))) +elif cmd == 'id': + print(load(args[0])[8:18].hex()) +elif cmd == 'plan': + e = load(args[0]); w, h, cur = int(args[1]), int(args[2]), float(args[3]) + lim, t = tmds_khz(e), shortest(e, w, h) + if not t: sys.exit(1) + listed = [x[0] for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim] + best = max(listed) if listed else cur + top = int(lim * 1000 // ((t[2] + t[3]) * (t[7] + t[8]))) + vmax = max_vrefresh(e) or int(max(x[0] for x in timings(e) if x[2] == w and x[7] == h)) + top = min(top, vmax) + # Not right at the limit: the top rounded down to ten, and the hundred + # below it as a safe option. Rates the display already lists are left + # out (its own timing is the safer one), and so is anything not faster + # than what already works. + steps = sorted({top // 10 * 10, top // 100 * 100}) + steps = [s for s in steps if s > best + 1 and not any(abs(s - x) < 1 for x in listed)] + print(lim // 1000, round(best, 2), *steps) +elif cmd == 'build': + e = load(args[0]); w, h = int(args[2]), int(args[3]); rates = [int(r) for r in args[4:]] + t = shortest(e, w, h) + n = nblocks(e) + e = e[:n * 128] + out = b'' + for hz in rates: + _, _, ha, hb, hf, hs, hp, va, vb, vf, vs, vp = t + clk = round((ha + hb) * (va + vb) * hz / 1e4) + x = (clk - 1).to_bytes(3, 'little') + bytes([0x08]) + for v, pol in ((ha, 0), (hb, 0), (hf, hp), (hs, 0), (va, 0), (vb, 0), (vf, vp), (vs, 0)): + x += ((v - 1) | pol << 15).to_bytes(2, 'little') + out += x + if out: + blk = bytearray(128) + blk[0:5] = bytes([0x70, 0x12, 121, 0x00, 0x00]) + blk[5:8] = bytes([0x03, 0x00, len(out)]) + blk[8:8 + len(out)] = out + blk[126] = (-sum(blk[1:126])) % 256 + blk[127] = (-sum(blk[:127])) % 256 + e += blk + count = len(e) // 128 - 1 + e[126] = count + e[127] = (-sum(e[:127])) % 256 + p = eeodb(e) + if p: + e[p] = count + i = p - p % 128 + e[i + 127] = (-sum(e[i:i + 127])) % 256 + open(args[1], 'wb').write(e) +PY +} + +hdmi_read_edid() { + # hdmi_read_edid : the display's complete EDID, + # read over DDC block by block (the kernel's copy may miss blocks). + local conn="$1" out="$2" bus n i tmp + bus="$(basename "$(readlink -f "/sys/class/drm/$conn/ddc")")"; bus="${bus#i2c-}" + tmp="$(mktemp)" + cat "/sys/class/drm/$conn/edid" > "$out" + if [[ -n "$bus" ]] && sudo modprobe i2c-dev 2>/dev/null; then + n="$(hdmi_edid_tool blocks "$out")" + : > "$tmp" + for (( i = 0; i < n; i++ )); do + # Segment pointer (0x30) selects each pair of blocks. + sudo i2ctransfer -y "$bus" w1@0x30 $(( i / 2 )) w1@0x50 $(( (i % 2) * 128 )) r128@0x50 2>/dev/null | + xxd -r -p >> "$tmp" 2>/dev/null || + { [[ $i -lt 2 ]] && { sudo i2ctransfer -y "$bus" w1@0x50 $(( i * 128 )) r128@0x50 | xxd -r -p >> "$tmp"; }; } || break + done + # Keep the DDC copy only when it's whole and the same display as the + # kernel's (not the whole block: a live override changes it). + if [[ "$(stat -c %s "$tmp")" == $(( n * 128 )) && + "$(hdmi_edid_tool id "$tmp")" == "$(hdmi_edid_tool id "$out")" ]]; then + cat "$tmp" > "$out" + fi + fi + rm -f "$tmp" +} + +hdmi_debugfs() { + # debugfs directory of a connector (one of several paths to the same + # GPU). Only root can list debugfs, so the glob runs under sudo. + local d + d="$(sudo sh -c 'for d in /sys/kernel/debug/dri/*/"$1"; do [ -e "$d/edid_override" ] && { echo "$d"; break; }; done' _ "${1#card*-}")" + [[ -n "$d" ]] && echo "$d" +} + +hdmi_override_live() { + # hdmi_override_live : swap the EDID the + # kernel uses right now, without a restart. + local d; d="$(hdmi_debugfs "$1")" || return 1 + # The kernel takes exactly "reset", without a newline. + if [[ "$2" == reset ]]; then printf reset | sudo tee "$d/edid_override" >/dev/null + else sudo tee "$d/edid_override" < "$2" >/dev/null; fi || return 1 + echo 1 | sudo tee "$d/trigger_hotplug" >/dev/null + sleep 3 +} + +hdmi_kscreen() { + # hdmi_kscreen current|mode [ ]: KDE's view of the + # output: "w h hz" of the current mode, or set the mode closest to hz. + kscreen-doctor -j 2>/dev/null | python3 -c ' +import json, sys +what, name = sys.argv[1], sys.argv[2] +for o in json.load(sys.stdin)["outputs"]: + if o["name"] != name: continue + modes = {m["id"]: m for m in o["modes"]} + if what == "current": + m = modes[o["currentModeId"]] + print(m["size"]["width"], m["size"]["height"], round(m["refreshRate"], 2)) + else: + w, h, hz = int(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) + ms = [m for m in o["modes"] if m["size"]["width"] == w and m["size"]["height"] == h] + if ms: print(min(ms, key=lambda m: abs(m["refreshRate"] - hz))["id"]) +' "$@" +} + +hdmi_set_mode() { + # hdmi_set_mode : returns 1 if KDE has no such mode. + local id rate + id="$(hdmi_kscreen mode "$@")" || return 1 + [[ -n "$id" ]] || return 1 + kscreen-doctor "output.$1.mode.$id" >/dev/null 2>&1 || return 1 + sleep 2 + rate="$(hdmi_kscreen current "$1" | cut -d' ' -f3)" + [[ "${rate%.*}" -ge $(( $4 - 1 )) ]] +} + +hdmi_confirm() { + # No answer (black screen, nobody there) is a no. + local reply + read -rt "$HDMI_CONFIRM_SECONDS" -p "$(echo -e "${c_bold}$1${c_reset} [y/N, ${HDMI_CONFIRM_SECONDS}s] ")" reply || { echo; return 1; } + [[ "$reply" =~ ^[Yy]$ ]] +} + +hdmi_boot_param() { + # hdmi_boot_param : set or remove our drm.edid_firmware= in + # the boot loader's command line, then rebuild the initramfs and entries. + local f value="$1" + f="$(hdmi_boot_file)" || return 1 + [[ -n "$f" ]] || { err "No supported boot loader (Limine, systemd-boot with sdboot-manage, GRUB) found."; return 1; } + backup_file "$f" + sudo sed -i -E 's/drm\.edid_firmware=[^ "]*steamify-[^ "]* ?//' "$f" + if [[ -n "$value" ]]; then + case "$f" in + # | as delimiter: the value holds a path. + /etc/default/limine) sudo sed -i -E "0,\|^KERNEL_CMDLINE\[default\]\+?=\"|s||&$value |" "$f" ;; + /etc/sdboot-manage.conf) sudo sed -i -E "0,\|^LINUX_OPTIONS=\"|s||&$value |" "$f" ;; + /etc/default/grub) sudo sed -i -E "0,\|^GRUB_CMDLINE_LINUX_DEFAULT=\"|s||&$value |" "$f" ;; + esac + grep -qF "$value" "$f" || { err "Couldn't add $value to $f."; return 1; } + fi + info "Rebuilding the initramfs and boot entries..." + case "$f" in + /etc/default/limine) sudo limine-mkinitcpio ;; + /etc/sdboot-manage.conf) sudo /usr/bin/mkinitcpio -P && sudo sdboot-manage gen ;; + /etc/default/grub) sudo /usr/bin/mkinitcpio -P && sudo grub-mkconfig -o /boot/grub/grub.cfg ;; + esac +} + +hdmi_tune() { + # hdmi_tune : find and confirm the highest rate + # at the desktop resolution. Sets HDMI_RESULT to " ..." (the + # confirmed new rates) or leaves it empty. + local conn="$1" tmp="$2" out="${1#card*-}" cur w h hz plan lim best steps_str + local -a steps ok_rates=() + HDMI_RESULT="" + # From the display's own EDID: a live one left by an earlier test would + # make its rates look like what the display already does. + hdmi_override_live "$conn" reset 2>/dev/null + cur="$(hdmi_kscreen current "$out")" + [[ -n "$cur" ]] || { warn "$out: KDE doesn't show this output; skipped."; return 0; } + read -r w h hz <<< "$cur" + info "$out: desktop resolution ${w}x${h} at ${hz} Hz. Reading the display's EDID..." + hdmi_read_edid "$conn" "$tmp/$out.orig" || return 1 + if ! plan="$(hdmi_edid_tool plan "$tmp/$out.orig" "$w" "$h" "$hz")"; then + warn "$out: the display lists no timing for ${w}x${h}; nothing to calculate." + return 0 + fi + read -r lim best steps_str <<< "$plan" + read -ra steps <<< "${steps_str:-}" + info "$out: HDMI limit ${lim} MHz, fastest mode that fits now: ${best} Hz." + if [[ ${#steps[@]} -eq 0 ]]; then + ok "$out: already at the highest rate that fits HDMI here; nothing to change." + return 0 + fi + info "$out: steps to try: ${steps[*]} Hz. After each one you're asked whether the" + info "picture is OK; no answer within ${HDMI_CONFIRM_SECONDS} s (e.g. a black screen) switches back." + ask_yn "Start the test?" y || return 0 + + hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.test" "$w" "$h" "${steps[@]}" && + hdmi_override_live "$conn" "$tmp/$out.test" || + { err "$out: couldn't load the test EDID (debugfs)."; return 1; } + local s + for s in "${steps[@]}"; do + info "$out: switching to ${w}x${h} at $s Hz..." + if hdmi_set_mode "$out" "$w" "$h" "$s" && hdmi_confirm "Is the picture OK at $s Hz?"; then + ok "$out: $s Hz works." + ok_rates+=("$s") + else + warn "$out: $s Hz doesn't work; stopping here." + break + fi + done + if [[ ${#ok_rates[@]} -eq 0 ]]; then + hdmi_override_live "$conn" reset + hdmi_set_mode "$out" "$w" "$h" "$hz" >/dev/null + info "$out: back to ${hz} Hz; nothing changed." + return 0 + fi + # Without the steps that failed, so KDE and gamescope never pick them. + hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.final" "$w" "$h" "${ok_rates[@]}" && + hdmi_override_live "$conn" "$tmp/$out.final" + hdmi_set_mode "$out" "$w" "$h" "${ok_rates[-1]}" >/dev/null + HDMI_RESULT="$w $h ${ok_rates[*]}" +} + +hdmi_enable() { + if [[ "${BACKEND:-false}" == true ]]; then + err "HDMI refresh boost needs you at the screen: run it from the Steamify menu in Konsole." + return 1 + fi + if [[ "${XDG_CURRENT_DESKTOP:-}" != *KDE* ]] || ! command -v kscreen-doctor >/dev/null; then + err "Run this from the Plasma desktop: the test switches the display mode through KDE." + return 1 + fi + if hdmi_status; then + local conn stale=false + for conn in $(hdmi_connectors); do + [[ -f "$HDMI_FW_DIR/steamify-${conn#card*-}.bin" ]] || continue + [[ "$(state_get hdmi "${conn#card*-}")" == "$(hdmi_edid_tool id "/sys/class/drm/$conn/edid")" ]] || stale=true + done + if [[ "$stale" == false ]]; then + ok "Already set up for the connected display(s). Untick and tick it again to re-test." + return 0 + fi + warn "Set up for another display; testing the one connected now." + hdmi_disable || return 1 + fi + sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; } + + local tmp conn out param="" files=() rc=0 + tmp="$(mktemp -d)" + for conn in $(hdmi_connectors); do + out="${conn#card*-}" + hdmi_tune "$conn" "$tmp" || { rc=1; continue; } + [[ -n "$HDMI_RESULT" ]] || continue + sudo install -Dm644 "$tmp/$out.final" "$HDMI_FW_DIR/steamify-$out.bin" || { rc=1; continue; } + state_set hdmi "$out" "$(hdmi_edid_tool id "$tmp/$out.orig")" + state_set hdmi "$out-mode" "$HDMI_RESULT" + param+="${param:+,}$out:edid/steamify-$out.bin" + files+=("$HDMI_FW_DIR/steamify-$out.bin") + done + rm -rf "$tmp" + [[ -n "$param" ]] || return $rc + + # amdgpu loads from the initramfs (the kms hook), before the root + # filesystem: the EDID file has to be in there too. + sudo mkdir -p "$(dirname "$HDMI_INITRAMFS_CONF")" + printf '# Written by steamify: EDID override for HDMI refresh boost.\nFILES+=(%s)\n' "${files[*]}" | + sudo tee "$HDMI_INITRAMFS_CONF" >/dev/null + if ! hdmi_boot_param "drm.edid_firmware=$param"; then + # Nothing half-done left behind; the live EDID lasts until a restart. + sudo rm -f "$HDMI_INITRAMFS_CONF" "${files[@]}" + for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done + state_clear hdmi + err "Making it permanent failed; the display is back on its own EDID." + return 1 + fi + ok "HDMI refresh boost is set up; it's active now and after every restart." + return $rc +} + +hdmi_disable() { + local f conn + # Drop-in and files first: the rebuild below leaves them out. + sudo rm -f "$HDMI_INITRAMFS_CONF" + for f in "$HDMI_FW_DIR"/steamify-*.bin; do [[ -e "$f" ]] && sudo rm -f "$f"; done + hdmi_boot_param "" || return 1 + for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done + state_clear hdmi + ok "HDMI refresh boost removed; the display uses its own EDID again." +} diff --git a/lib/menu.sh b/lib/menu.sh index bf8f3cf..6ed5eb4 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -5,17 +5,18 @@ # Menu order. Components are turned on in this order and off in reverse; # gaming must come first (single user builds on it). -COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin bios) +COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin hdmi bios) # One-off actions rather than on/off components: never preselected, never # re-applied, not listed as on or off. ACTIONS=(bios) # Sub-options, shown indented under their parent and only while it's ticked. -declare -A PARENT=([boot]=gaming [kpin]=machine) +declare -A PARENT=([boot]=gaming [kpin]=machine [hdmi]=machine [bios]=machine) # Never preselected on a first run: booting into the desktop is a choice, # gamescope is the default; HDMI-CEC is opt-in (it can wake the machine or # upset other devices on the TV, even on SteamOS), except on a Steam Machine, -# which has CEC like on SteamOS. -NO_PRESELECT=(boot cec) +# which has CEC like on SteamOS. HDMI refresh boost needs someone at the +# screen to confirm each step. +NO_PRESELECT=(boot cec hdmi) declare -A LABEL=( [gaming]="SteamOS conversion: boot into gaming mode, Steam on the desktop" @@ -27,6 +28,7 @@ declare -A LABEL=( [cec]="HDMI-CEC: use Steam with the TV remote, TV on/off with the PC (experimental)" [machine]="Steam Machine support: LED bar driver, hardware settings in Steam" [kpin]="Pin the kernel to $PINNED_KERNEL_VER (fixes rebooting after shutdown)" + [hdmi]="HDMI refresh boost: highest refresh your HDMI display runs" [bios]="Update BIOS" ) declare -A CURRENT WANTED @@ -34,6 +36,7 @@ declare -A CURRENT WANTED component_available() { case "$1" in machine|kpin) machine_available ;; + hdmi) hdmi_available ;; bios) bios_available ;; esac } @@ -93,6 +96,12 @@ toggle_component() { # The kernel pin is opt-out: ticked along with Steam Machine support. if [[ "$c" == machine ]]; then WANTED[kpin]=${WANTED[machine]}; fi if [[ "$c" == kpin && "${WANTED[kpin]}" == 1 ]]; then WANTED[machine]=1; fi + # HDMI refresh boost and the BIOS update sit under Steam Machine support; + # the boost also needs the pinned kernel (newer kernels don't need it). + if [[ "$c" == machine && "${WANTED[machine]}" == 0 ]]; then WANTED[hdmi]=0; WANTED[bios]=0; fi + if [[ "$c" == kpin && "${WANTED[kpin]}" == 0 ]]; then WANTED[hdmi]=0; fi + if [[ "$c" == hdmi && "${WANTED[hdmi]}" == 1 ]]; then WANTED[machine]=1; WANTED[kpin]=1; fi + if [[ "$c" == bios && "${WANTED[bios]}" == 1 ]]; then WANTED[machine]=1; fi } show_menu() { @@ -115,12 +124,13 @@ show_menu() { [[ "${CURRENT[gaming]}" == 1 ]] || now="-" want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk" printf " %-3s %-6s %-6s └ %s\n" "$i" "$now" "$want" "$(boot_choice "${WANTED[boot]}")" + elif ! component_selectable "$c"; then + local tree=""; [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " + printf " %b%-3s %-6s %-6s %s%s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "$tree" "${LABEL[$c]}" "$c_reset" elif [[ -n "${PARENT[$c]:-}" ]]; then printf " %-3s %b %-6s └ %s\n" "$i" "$now" "$want" "${LABEL[$c]}" - elif component_selectable "$c"; then - printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}" else - printf " %b%-3s %-6s %-6s %s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "${LABEL[$c]}" "$c_reset" + printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}" fi done echo @@ -168,8 +178,9 @@ draw_menu_tui() { fi if ! component_selectable "$c"; then # Greyed out: nothing to do (e.g. BIOS already up to date). - local mark=" "; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " - echo -e "${mark}${c_dim}[ ] ${LABEL[$c]} (not available)${c_reset}" + local mark=" " tree=""; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " + [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " + echo -e "${mark}${c_dim}${tree}[ ] ${LABEL[$c]} (not available)${c_reset}" elif (( i == cursor )); then # The green x's reset would end the bold too: re-enable it after. echo -e " ${c_cyan}>${c_reset} ${c_bold}${line//"$c_reset"/"$c_reset$c_bold"}${c_reset}${state}" diff --git a/steamify.sh b/steamify.sh index c66b9fb..37da315 100755 --- a/steamify.sh +++ b/steamify.sh @@ -15,11 +15,11 @@ set -uo pipefail # Release version, see CHANGELOG.md. -VERSION=1.1.4 +VERSION=1.2.0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -for lib in common state packages login-manager single-user steam-desktop steam-machine cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu; do +for lib in common state packages login-manager single-user steam-desktop steam-machine hdmi-refresh cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu; do # shellcheck source=/dev/null source "$SCRIPT_DIR/lib/$lib.sh" done