Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,27 @@ All notable DeckDoc changes are recorded here. The project follows

## [Unreleased]

No unreleased changes.
### Added

- External-display blackout coverage for desktop mode (#29): `display_blackout.sh` now inspects
connected non-eDP connectors in a KWin session, matches each display's EDID against the stored
KWin output policy, and emits `EXTERNAL_BLACKOUT_SIGNATURE: FULLSCREEN_SYNC_POLICY_GAP`,
`SYNC_POLICY_UNDETERMINED` (policy unreadable — treated as unpinned, never as fixed), or
`SYNC_POLICY_ALREADY_PINNED` when a physical-black symptom is declared with a healthy deck-side
link — the discriminator for fullscreen adaptive-sync blanking on a monitor or dock chain. The
internal and external assessments are independent, so an extended desktop reports both.
- A guarded `rem_external_display.sh` remediation behind the new `--fix-external-display-blackout`
flag: pins adaptive sync off (`kscreen-doctor vrrpolicy.never`) for connected external outputs
after desktop-session and symptom prechecks, with config backup and verification scoped to each
targeted output (live state and per-EDID persistence). Shares the internal-panel remediation's
no-power/brightness/clock-write contract; the pinned policy itself persists per-display by KWin
design and is reversible via `vrrpolicy.automatic` or the saved backup.

### Fixed

- A connected-but-disabled internal panel (docked, external-only setups) no longer claims the
internal `LIVE_RENDER_TO_PHYSICAL_SCANOUT_GAP` signature ahead of an active external display;
the panel must be enabled in the output configuration for that classification.

## [3.4.0] - 2026-07-21

Expand Down
15 changes: 15 additions & 0 deletions deckdoc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ umask 077
FIX_MODE=false
DISPLAY_BLACK_REPORTED=false
DISPLAY_FIX_MODE=false
EXTERNAL_DISPLAY_FIX_MODE=false
PERSIST_DISPLAY_STABILITY=false
for arg in "$@"; do
case "$arg" in
Expand All @@ -14,6 +15,10 @@ for arg in "$@"; do
DISPLAY_BLACK_REPORTED=true
DISPLAY_FIX_MODE=true
;;
--fix-external-display-blackout)
DISPLAY_BLACK_REPORTED=true
EXTERNAL_DISPLAY_FIX_MODE=true
;;
--persist-display-stability)
DISPLAY_BLACK_REPORTED=true
DISPLAY_FIX_MODE=true
Expand Down Expand Up @@ -145,5 +150,15 @@ if [ "$DISPLAY_FIX_MODE" = true ]; then
sync
fi

# External-display remediation targets the KWin desktop path, not gamescope; it
# shares the explicit physical-black symptom gate with the internal-panel fix.
if [ "$EXTERNAL_DISPLAY_FIX_MODE" = true ]; then
echo "" >> "${REPORT_FILE}"
echo "=== EXTERNAL DISPLAY REMEDIATION PHASE ===" >> "${REPORT_FILE}"
"${MODULES_DIR}/rem_external_display.sh" 2>&1 | "$REDACTOR" >> "${REPORT_FILE}"
echo "" >> "${REPORT_FILE}"
sync
fi

sync
exit 0
106 changes: 94 additions & 12 deletions modules/display_blackout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ sync

echo "--- DRM connector status ---"
EDP_DIR=""
EDP_ENABLED="not-exposed"
for conn in "${SYS_ROOT}"/class/drm/*/status; do
[ -f "$conn" ] || continue
connector_dir=$(dirname "$conn")
Expand All @@ -38,6 +39,7 @@ for conn in "${SYS_ROOT}"/class/drm/*/status; do
echo " ${name}: status=${status}, enabled=${enabled}"
if [ "$status" = "connected" ] && echo "$name" | grep -qi 'edp'; then
EDP_DIR="$connector_dir"
EDP_ENABLED="$enabled"
fi
done

Expand Down Expand Up @@ -109,6 +111,54 @@ fi
echo " Active hardware planes with framebuffers: ${ACTIVE_PLANES}"
sync

echo "--- External display / desktop session (read-only) ---"
SESSION_HOME=$(getent passwd "$SESSION_USER" 2>/dev/null | cut -d: -f6)
KWIN_OUTPUT_CONFIG="${DECKDOC_KWIN_OUTPUT_CONFIG:-${SESSION_HOME}/.config/kwinoutputconfig.json}"
KWIN_ACTIVE=false
if [ "${DECKDOC_KWIN_ACTIVE:-auto}" = "auto" ]; then
if pgrep -x kwin_wayland >/dev/null 2>&1; then KWIN_ACTIVE=true; fi
else
KWIN_ACTIVE="${DECKDOC_KWIN_ACTIVE}"
fi
echo " KWin desktop session active: ${KWIN_ACTIVE}"
EXTERNAL_CONNECTED=false
ANY_VRR_UNPINNED=false
ANY_VRR_UNKNOWN=false
for conn in "${SYS_ROOT}"/class/drm/*/status; do
[ -f "$conn" ] || continue
connector_dir=$(dirname "$conn")
name=$(basename "$connector_dir")
case "$name" in *eDP*|*Writeback*) continue ;; esac
[ "$(read_value "$conn")" = "connected" ] || continue
EXTERNAL_CONNECTED=true
ext_edid_bytes=$(wc -c < "${connector_dir}/edid" 2>/dev/null || echo 0)
echo " ${name}: connected, EDID=${ext_edid_bytes} bytes"
if [ -r "${connector_dir}/modes" ]; then head -3 "${connector_dir}/modes" | sed 's/^/ mode: /'; fi
# KWin persists per-display policy keyed by the EDID md5; match this
# connector against the session's stored output configuration. Track
# unpinned and unreadable states across every connector rather than a
# single last-writer flag so one pinned display cannot mask another.
vrr_policy=""
if [ "$ext_edid_bytes" -gt 0 ] && [ -r "$KWIN_OUTPUT_CONFIG" ] && command -v md5sum >/dev/null 2>&1; then
edid_hash=$(md5sum "${connector_dir}/edid" 2>/dev/null | cut -d' ' -f1)
vrr_policy=$(awk -v hash="$edid_hash" '
/"edidHash"/ { entry_hash = ($0 ~ hash) }
entry_hash && /"vrrPolicy"/ {
gsub(/[",]/, ""); print $2; exit
}
' "$KWIN_OUTPUT_CONFIG" 2>/dev/null)
fi
if [ -n "$vrr_policy" ]; then
echo " ${name}: stored vrrPolicy=${vrr_policy}"
if [ "$vrr_policy" != "Never" ]; then ANY_VRR_UNPINNED=true; fi
else
echo " ${name}: stored sync policy could not be determined for this display."
ANY_VRR_UNKNOWN=true
fi
done
if [ "$EXTERNAL_CONNECTED" = "false" ]; then echo " No external display connector is connected."; fi
sync

echo "--- Gamescope composition state ---"
if [ "${DECKDOC_SKIP_GAMESCOPE:-0}" = "1" ]; then
echo " Skipped by test environment."
Expand Down Expand Up @@ -154,18 +204,50 @@ fi
sync

echo "--- Display-path assessment ---"
if [ "$DISPLAY_BLACK_REPORTED" = "true" ] && [ -n "$EDP_DIR" ] && [ "$EDP_EDID_BYTES" -gt 0 ] && [ "$BACKLIGHT_LIT" = "true" ] && [ "$CRTC_ACTIVE" = "true" ]; then
echo " BLACKOUT_SIGNATURE: LIVE_RENDER_TO_PHYSICAL_SCANOUT_GAP"
echo " eDP link/EDID, backlight, and CRTC remain live while the physical panel is reported black."
if [ "$ACTIVE_PLANES" -gt 1 ]; then
echo " Multi-plane scanout is active (${ACTIVE_PLANES} planes); test forced composition with --fix-display-blackout."
else
echo " Single-plane composition is already active; continue panel-link/kernel investigation."
fi
elif [ "$DISPLAY_BLACK_REPORTED" = "true" ]; then
echo " BLACKOUT_SIGNATURE: PANEL_OR_MODESET_STATE_INCOMPLETE"
echo " A panel-link, EDID, backlight, or CRTC check failed; forced composition is not automatically indicated."
else
if [ "$DISPLAY_BLACK_REPORTED" != "true" ]; then
echo " No physical-black symptom was declared. Use --display-black while it is present."
else
# The internal and external assessments are independent facts: an extended
# desktop can have a live panel and an unpinned external display at once,
# and neither signature may shadow the other.
EXTERNAL_ASSESSED=false
if [ "$EXTERNAL_CONNECTED" = "true" ] && [ "$KWIN_ACTIVE" = "true" ]; then
# A user-visible external blank with a healthy deck-side connector state
# is the discriminator for a presentation-path or downstream fault: the
# kernel keeps scanning out while the monitor loses the picture. KWin
# engages adaptive sync and direct scanout only for fullscreen surfaces,
# which is why the desktop is stable and games are not.
EXTERNAL_ASSESSED=true
if [ "$ANY_VRR_UNPINNED" = "true" ]; then
echo " EXTERNAL_BLACKOUT_SIGNATURE: FULLSCREEN_SYNC_POLICY_GAP"
echo " External connector is live with adaptive sync not pinned off; the monitor or dock"
echo " chain may blank on fullscreen VRR engagement. Test --fix-external-display-blackout."
elif [ "$ANY_VRR_UNKNOWN" = "true" ]; then
# An unreadable policy must never be reported as a confirmed fix;
# the safe direction is to recommend the reversible remediation.
echo " EXTERNAL_BLACKOUT_SIGNATURE: SYNC_POLICY_UNDETERMINED"
echo " The stored sync policy could not be read for at least one external display; treat"
echo " it as unpinned and test --fix-external-display-blackout before deeper investigation."
else
echo " EXTERNAL_BLACKOUT_SIGNATURE: SYNC_POLICY_ALREADY_PINNED"
echo " Adaptive sync is already pinned off for every external display; continue dock-segment,"
echo " cable, and monitor investigation (the deck-side link shows no fault)."
fi
fi
# A connected panel that is disabled in the output configuration (docked,
# lid use, external-only setups) still reports a lit backlight; it must not
# claim the blackout signature while inactive.
if [ -n "$EDP_DIR" ] && [ "$EDP_ENABLED" != "disabled" ] && [ "$EDP_EDID_BYTES" -gt 0 ] && [ "$BACKLIGHT_LIT" = "true" ] && [ "$CRTC_ACTIVE" = "true" ]; then
echo " BLACKOUT_SIGNATURE: LIVE_RENDER_TO_PHYSICAL_SCANOUT_GAP"
echo " eDP link/EDID, backlight, and CRTC remain live while the physical panel is reported black."
if [ "$ACTIVE_PLANES" -gt 1 ]; then
echo " Multi-plane scanout is active (${ACTIVE_PLANES} planes); test forced composition with --fix-display-blackout."
else
echo " Single-plane composition is already active; continue panel-link/kernel investigation."
fi
elif [ "$EXTERNAL_ASSESSED" != "true" ]; then
echo " BLACKOUT_SIGNATURE: PANEL_OR_MODESET_STATE_INCOMPLETE"
echo " A panel-link, EDID, backlight, or CRTC check failed; forced composition is not automatically indicated."
fi
fi
sync
147 changes: 147 additions & 0 deletions modules/rem_external_display.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -uo pipefail

echo "[REMEDIATION: External Display Blackout / Adaptive-Sync Pinning]"
SYS_ROOT="${DECKDOC_SYS_ROOT:-/sys}"
DECKDOC_DIR="${DECKDOC_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
REPORTED="${DECKDOC_DISPLAY_BLACK_REPORTED:-false}"

SESSION_USER="${DECKDOC_SESSION_USER:-${SUDO_USER:-$(id -un)}}"
if [ "$SESSION_USER" = "root" ]; then
SESSION_USER=$(loginctl list-users --no-legend 2>/dev/null | awk '$2 != "root" { print $2; exit }')
fi
SESSION_UID=$(id -u "$SESSION_USER" 2>/dev/null || echo "")
SESSION_HOME=$(getent passwd "$SESSION_USER" 2>/dev/null | cut -d: -f6)
KWIN_OUTPUT_CONFIG="${DECKDOC_KWIN_OUTPUT_CONFIG:-${SESSION_HOME}/.config/kwinoutputconfig.json}"

run_session() {
if [ "$(id -un)" = "$SESSION_USER" ]; then
XDG_RUNTIME_DIR="/run/user/${SESSION_UID}" WAYLAND_DISPLAY="${WAYLAND_SOCKET}" "$@"
else
runuser -u "$SESSION_USER" -- env XDG_RUNTIME_DIR="/run/user/${SESSION_UID}" WAYLAND_DISPLAY="${WAYLAND_SOCKET}" "$@"
fi
}

echo "--- PRE_CHECK ---"
if [ "$REPORTED" != "true" ]; then
echo "SKIPPED: Physical-black symptom was not explicitly reported."
echo "REMEDIATION_OUTCOME: SKIPPED (symptom not declared)"
exit 0
fi
if [ -z "$SESSION_USER" ] || [ -z "$SESSION_UID" ] || [ -z "$SESSION_HOME" ]; then
echo "FAILED: Could not resolve the active non-root desktop user."
echo "REMEDIATION_OUTCOME: FAILED (session user unresolved)"
exit 1
fi
if ! pgrep -x kwin_wayland >/dev/null 2>&1; then
echo "SKIPPED: No KWin desktop session is active. Use --fix-display-blackout for Game Mode."
echo "REMEDIATION_OUTCOME: SKIPPED (KWin inactive)"
exit 0
fi
if ! command -v kscreen-doctor >/dev/null 2>&1; then
echo "FAILED: kscreen-doctor is not available."
echo "REMEDIATION_OUTCOME: FAILED (kscreen-doctor missing)"
exit 1
fi
WAYLAND_SOCKET=""
for sock in "/run/user/${SESSION_UID}"/wayland-*; do
[ -S "$sock" ] || continue
case "$sock" in *.lock) continue ;; esac
WAYLAND_SOCKET=$(basename "$sock")
break
done
if [ -z "$WAYLAND_SOCKET" ]; then
echo "FAILED: No Wayland socket found for the desktop session."
echo "REMEDIATION_OUTCOME: FAILED (no Wayland socket)"
exit 1
fi
EXTERNAL_OUTPUTS=""
for conn in "${SYS_ROOT}"/class/drm/*/status; do
[ -f "$conn" ] || continue
connector_dir=$(dirname "$conn")
name=$(basename "$connector_dir")
case "$name" in *eDP*|*Writeback*) continue ;; esac
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
# KWin names outputs without the DRM card prefix (card0-DP-1 -> DP-1).
EXTERNAL_OUTPUTS="${EXTERNAL_OUTPUTS} ${name#card*-}"
done
EXTERNAL_OUTPUTS="${EXTERNAL_OUTPUTS# }"
if [ -z "$EXTERNAL_OUTPUTS" ]; then
echo "SKIPPED: No external display connector is connected."
echo "REMEDIATION_OUTCOME: SKIPPED (no external display)"
exit 0
fi
echo "PASS: KWin active, Wayland socket ${WAYLAND_SOCKET}, external output(s): ${EXTERNAL_OUTPUTS}"

echo "--- BACKUP ---"
BACKUP_DIR="${DECKDOC_DIR}/remediation_backups"
mkdir -p "$BACKUP_DIR"
STAMP=$(date +%s)
if [ -r "$KWIN_OUTPUT_CONFIG" ]; then
cp -a "$KWIN_OUTPUT_CONFIG" "${BACKUP_DIR}/kwinoutputconfig.json.${STAMP}.bak"
echo "Saved pre-change output policy: ${BACKUP_DIR}/kwinoutputconfig.json.${STAMP}.bak"
else
echo "NOTE: No existing KWin output configuration file to back up."
fi
run_session kscreen-doctor -o > "${BACKUP_DIR}/kscreen_pre_${STAMP}.txt" 2>&1 || true

echo "--- EXECUTE ---"
# Pinning adaptive sync off changes fullscreen VRR engagement only. It does not
# change resolution, refresh rate, panel power, brightness, TDP, or GPU clocks.
APPLIED=""
for output in $EXTERNAL_OUTPUTS; do
if run_session kscreen-doctor "output.${output}.vrrpolicy.never" >/dev/null 2>&1; then
echo "Applied vrrpolicy=never on ${output}."
APPLIED="${APPLIED} ${output}"
else
echo "FAILED: kscreen-doctor rejected vrrpolicy change on ${output}."
fi
done
if [ -z "${APPLIED# }" ]; then
echo "REMEDIATION_OUTCOME: FAILED (no output accepted the policy)"
exit 1
fi

echo "--- VERIFY ---"
sleep 1
# Verification is scoped to each output this run actually changed; a policy
# already pinned on some other display (or a stale per-EDID entry from an
# earlier fix) must never satisfy the check for this one.
LIVE_STATE=$(run_session kscreen-doctor -o 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')
VERIFY_FAILED=false
for output in $APPLIED; do
live_vrr=$(printf '%s\n' "$LIVE_STATE" | awk -v out="$output" '
/^Output:/ { in_block = ($3 == out) }
in_block && /^[[:space:]]*Vrr:/ { print $2; exit }')
if [ "$live_vrr" = "Never" ]; then
echo "PASS: ${output} reports adaptive sync pinned off in the live output state."
else
echo "FAILED: ${output} live state reports \"${live_vrr:-no policy}\" instead of a pinned sync policy."
VERIFY_FAILED=true
fi
done
if [ "$VERIFY_FAILED" = "true" ]; then
echo "REMEDIATION_OUTCOME: FAILED (live verification)"
exit 1
fi
for output in $APPLIED; do
edid_file=""
for candidate in "${SYS_ROOT}"/class/drm/card*-"${output}"/edid; do
[ -r "$candidate" ] && { edid_file="$candidate"; break; }
done
stored=""
if [ -n "$edid_file" ] && [ -r "$KWIN_OUTPUT_CONFIG" ] && command -v md5sum >/dev/null 2>&1; then
edid_hash=$(md5sum "$edid_file" 2>/dev/null | cut -d' ' -f1)
stored=$(awk -v hash="$edid_hash" '
/"edidHash"/ { entry_hash = ($0 ~ hash) }
entry_hash && /"vrrPolicy"/ { gsub(/[",]/, ""); print $2; exit }
' "$KWIN_OUTPUT_CONFIG" 2>/dev/null)
fi
if [ "$stored" = "Never" ]; then
echo "PASS: ${output} policy persisted for this display's EDID (survives reboot, keyed to this display)."
else
echo "NOTE: ${output}: persistence not yet visible for this display's EDID; KWin may write it on session exit."
fi
done
echo "NOTE: Software cannot observe the monitor's panel; confirm blanking stops during fullscreen use."
echo "REMEDIATION_OUTCOME: PARTIAL (sync policy pinned; physical confirmation required)"
Loading
Loading