diff --git a/AGENTS.md b/AGENTS.md index c6155dd..cfd0a2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,8 +186,13 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine =x:`; without `--hdmi` the backend refuses it. 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. + replaces the kernel's copy. The override is never on the kernel command + line (that applied it to any display on the port): `steamify-edid-hotplug` + (boot unit + udev drm hotplug rule) loads it only while the display whose + DDC ID is in `/etc/steamify/hdmi-edid.conf` is connected, else resets. + It records the state in `/run/steamify-edid` before its own + `trigger_hotplug`, whose event runs it again. Pre-2.1.0 command-line + setups are removed by `hdmi_remove_boot_param`. - Steam Machine CEC driver (`cec_driver_enable`, `lib/cec.sh`): mainline `cros_ec_cec` lacks Fremont, so HDMI-CEC builds Valve's copy (evlaV `linux-integration`, pinned commit + SHA-256) with DKMS for every kernel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6998843..9ba75e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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. +## 2.1.0 - 2026-09-26 + +- **feat: HDMI refresh boost only for the display it was set up for** + - The EDID override was on the kernel command line, so any display on that + HDMI port got the first display's timings. It's now loaded at boot and + at every hotplug only when the connected display is the one it was + tested on (its ID read over DDC); another display, or unplugging, puts + the port back on the display's own EDID. No initramfs or boot loader + change any more. An existing setup is moved over when re-applied (`a`). + ## 2.0.3 - 2026-09-26 - **fix: HDMI-CEC on the Steam Machine** diff --git a/TECHNICAL.md b/TECHNICAL.md index ce1b8bb..66f7f56 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -259,16 +259,24 @@ ticked by default, run from the desktop in Konsole): 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`. +5. Keeps only the confirmed steps, for that display only: + `/usr/lib/firmware/edid/steamify-.bin`, and the display's ID + (manufacturer, model, serial, date: EDID bytes 8-17) in + `/etc/steamify/hdmi-edid.conf`. `steamify-edid.service` (at boot, before + the login manager) and a udev rule (`90-steamify-edid.rules`, every drm + hotplug) run `/usr/local/bin/steamify-edid-hotplug`, which reads the + connected display's ID over DDC (the real display, even while an override + is loaded) and loads the file through debugfs only when it matches. + Another display, or no display, resets the port to the display's own + EDID, so a different monitor never gets the boosted timings. The state + per port is kept in `/run/steamify-edid`, so the hotplug the script + triggers itself doesn't loop. -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 +Versions before 2.1.0 used `drm.edid_firmware=` on the kernel command line +(and the initramfs), which applied to any display on that port; re-applying +moves such a setup over to the hotplug script. Re-applying with another +display connected tests that one instead. Turning it off removes the files, +the unit and the rule. Untick it before removing the kernel pin: newer kernels read the EDID themselves and can do HDMI 2.1. ## BIOS updates (Steam Machine) diff --git a/lib/hdmi-refresh.sh b/lib/hdmi-refresh.sh index 1dda1b2..f2e484f 100644 --- a/lib/hdmi-refresh.sh +++ b/lib/hdmi-refresh.sh @@ -1,8 +1,8 @@ #!/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: +# tests it live and keeps it with an EDID override that's only loaded while +# that display is connected (see hdmi_install_hotplug). 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. @@ -14,7 +14,14 @@ # black). Sourced by steamify.sh; not meant to be run on its own. HDMI_FW_DIR=/usr/lib/firmware/edid +# Before 2.1.0 the override was on the kernel command line; only removed now. HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf +# " " per line: which display each EDID file is for. +HDMI_MAP=/etc/steamify/hdmi-edid.conf +HDMI_HOTPLUG=/usr/local/bin/steamify-edid-hotplug +HDMI_UNIT_NAME=steamify-edid.service +HDMI_UNIT="/etc/systemd/system/$HDMI_UNIT_NAME" +HDMI_UDEV_RULE=/etc/udev/rules.d/90-steamify-edid.rules # 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. @@ -48,10 +55,14 @@ hdmi_cmdline_param() { 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 || { pinned_kernel_installed && [[ -n "$(hdmi_connectors)" ]]; } } -hdmi_status() { [[ -n "$(hdmi_cmdline_param)" ]] && compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null; } +hdmi_status() { + # The command line: set up by an older version, re-applying moves it over. + { [[ -f "$HDMI_UNIT" ]] || [[ -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 @@ -322,23 +333,16 @@ hdmi_confirm() { [[ "$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; } +hdmi_remove_boot_param() { + # Removes the drm.edid_firmware= older versions put on the kernel command + # line (it applied to whatever display was on that port), then rebuilds + # the initramfs and boot entries. + local f + [[ -n "$(hdmi_cmdline_param)" ]] || { sudo rm -f "$HDMI_INITRAMFS_CONF"; return 0; } + f="$(hdmi_boot_file)" 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 + sudo rm -f "$HDMI_INITRAMFS_CONF" info "Rebuilding the initramfs and boot entries..." case "$f" in /etc/default/limine) sudo limine-mkinitcpio ;; @@ -347,6 +351,87 @@ hdmi_boot_param() { esac } +hdmi_install_hotplug() { + # The EDID file of an output is only loaded while the display it was + # tested on is connected: a udev rule runs the script at every hotplug + # (and a unit at boot, before the login manager). The script reads the + # display's ID over DDC, which shows the real display even while an + # override is loaded, and loads the file or resets to the display's own + # EDID. Another display on that port gets its own EDID, and unplugging + # resets it, so the next display never starts on the wrong one. + local f out map="" + for f in "$HDMI_FW_DIR"/steamify-*.bin; do + [[ -e "$f" ]] || continue + out="${f##*/steamify-}"; out="${out%.bin}" + map+="$out $(state_get hdmi "$out")"$'\n' + done + [[ -n "$map" ]] || return 1 + sudo mkdir -p "$(dirname "$HDMI_MAP")" + printf '# Written by steamify: HDMI refresh boost, .\n%s' "$map" | + sudo tee "$HDMI_MAP" >/dev/null || return 1 + sudo tee "$HDMI_HOTPLUG" > /dev/null << 'EOF' +#!/bin/bash +# Steamify CachyOS, HDMI refresh boost: load an output's EDID override only +# while the display it was made for is connected. Runs as root (debugfs). +MAP=/etc/steamify/hdmi-edid.conf FW=/usr/lib/firmware/edid RUN=/run/steamify-edid +modprobe i2c-dev 2>/dev/null +mkdir -p "$RUN" +# Hotplug events come in bursts, and the link needs a moment before DDC works. +sleep 1 +for c in /sys/class/drm/card*-HDMI-A-*; do + [ -e "$c" ] || continue + out="${c##*/}"; out="${out#card*-}" + want="$(awk -v o="$out" '$1 == o { print $2 }' "$MAP")" + [ -n "$want" ] && [ -f "$FW/steamify-$out.bin" ] || continue + new=reset + if [ "$(cat "$c/status")" = connected ]; then + bus="$(basename "$(readlink -f "$c/ddc")")" + for i in 1 2 3 4 5; do + # Bytes 8-17 of block 0: manufacturer, product, serial, date. + id="$(i2ctransfer -y "${bus#i2c-}" w1@0x50 0 r18@0x50 2>/dev/null | tr -d ' ' | sed 's/0x//g')" + [ ${#id} -eq 36 ] && break + sleep 1 + done + [ "${id:16}" = "$want" ] && new=override + fi + [ "$(cat "$RUN/$out" 2>/dev/null || echo reset)" = "$new" ] && continue + for d in /sys/kernel/debug/dri/*/"$out"; do [ -e "$d/edid_override" ] && break; done + [ -e "$d/edid_override" ] || continue + if [ "$new" = override ]; then cat "$FW/steamify-$out.bin" > "$d/edid_override" + else printf reset > "$d/edid_override"; fi + # Before the hotplug below, whose own event runs this again. + echo "$new" > "$RUN/$out" + echo 1 > "$d/trigger_hotplug" +done +exit 0 +EOF + sudo chmod 755 "$HDMI_HOTPLUG" + # StartLimitIntervalSec=0: a burst of hotplugs must not get it rate-limited. + sudo tee "$HDMI_UNIT" > /dev/null << EOF +[Unit] +Description=Steamify CachyOS: HDMI refresh boost EDID for the connected display +StartLimitIntervalSec=0 +Wants=sys-kernel-debug.mount +After=sys-kernel-debug.mount +Before=display-manager.service sddm.service plasmalogin.service + +[Service] +Type=oneshot +ExecStart=$HDMI_HOTPLUG + +[Install] +WantedBy=graphical.target +EOF + # restart, not start: a hotplug during a run must still be looked at. + printf '%s\n' '# Written by steamify: HDMI refresh boost, check the display at every hotplug.' \ + "ACTION==\"change\", SUBSYSTEM==\"drm\", ENV{HOTPLUG}==\"1\", RUN+=\"/usr/bin/systemctl --no-block restart $HDMI_UNIT_NAME\"" | + sudo tee "$HDMI_UDEV_RULE" >/dev/null || return 1 + sudo systemctl daemon-reload + sudo udevadm control --reload + sudo systemctl enable "$HDMI_UNIT_NAME" >/dev/null 2>&1 || { err "Enabling $HDMI_UNIT_NAME failed."; return 1; } + sudo systemctl restart "$HDMI_UNIT_NAME" +} + hdmi_tune() { # hdmi_tune : find and confirm the highest rate # at the desktop resolution. Sets HDMI_RESULT to " ..." (the @@ -499,6 +584,12 @@ hdmi_enable() { [[ -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 && -n "$(hdmi_cmdline_param)" ]]; then + info "Moving the EDID override off the kernel command line, so it only applies to this display..." + hdmi_remove_boot_param && hdmi_install_hotplug || { err "Moving it failed."; return 1; } + ok "HDMI refresh boost now only applies to the display it was set up for." + return 0 + fi if [[ "$stale" == false ]]; then ok "Already set up for the connected display(s). Untick and tick it again to re-test." return 0 @@ -508,7 +599,7 @@ hdmi_enable() { 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 res + local tmp conn out files=() rc=0 res tmp="$(mktemp -d)" if [[ ${#HDMI_CHOICE[@]} -gt 0 ]]; then # Picked and tested in the app. @@ -524,35 +615,29 @@ hdmi_enable() { 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" "${res#*|}" - param+="${param:+,}$out:edid/steamify-$out.bin" files+=("$HDMI_FW_DIR/steamify-$out.bin") done rm -rf "$tmp" "$HDMI_CACHE" - [[ -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 + [[ ${#files[@]} -gt 0 ]] || return $rc + if ! hdmi_install_hotplug; 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 + hdmi_disable >/dev/null 2>&1 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." + ok "HDMI refresh boost is set up for this display; another display gets its own settings." return $rc } hdmi_disable() { local f conn - # Drop-in and files first: the rebuild below leaves them out. - sudo rm -f "$HDMI_INITRAMFS_CONF" + sudo systemctl disable "$HDMI_UNIT_NAME" >/dev/null 2>&1 + sudo rm -f "$HDMI_UDEV_RULE" "$HDMI_UNIT" "$HDMI_HOTPLUG" "$HDMI_MAP" + sudo rm -rf /run/steamify-edid + sudo systemctl daemon-reload + sudo udevadm control --reload for f in "$HDMI_FW_DIR"/steamify-*.bin; do [[ -e "$f" ]] && sudo rm -f "$f"; done - hdmi_boot_param "" || return 1 + hdmi_remove_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/steamify.sh b/steamify.sh index d45fedd..4eb66f4 100755 --- a/steamify.sh +++ b/steamify.sh @@ -15,7 +15,7 @@ set -uo pipefail # Release version, see CHANGELOG.md. -VERSION=2.0.3 +VERSION=2.1.0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"