From 1f1873a18e36e575b7d366c1db26112ab5ae7939 Mon Sep 17 00:00:00 2001 From: 0x1Nawaf Date: Fri, 23 Oct 2026 15:46:00 +0000 Subject: [PATCH 1/4] fix: compress the client only after the game window is focused --- .../overlay/usr/local/bin/droiddeck-session | 19 +++++++-- tools/tests/test_client_zram_focus.py | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tools/tests/test_client_zram_focus.py diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 688d0c19b..df89589e5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -227,7 +227,9 @@ if [ -z "${BL_INSIDE:-}" ]; then echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" fi fi - if [ "$mode" = steam ]; then + # Off unless Performance asks for it. Compositing the cursor here adds a second + # plane on the phone's compositor; a controller-driven cursor is the reason to turn it on. + if [ "$mode" = steam ] && [ "${BL_CURSOR_COMPOSITE:-0}" = 1 ]; then export gamescope_cursor_composite=2 fi args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") @@ -475,19 +477,30 @@ case $mode in bl_zram_scan step "zRAM: done ($(bl_zram_usage))" } + # The game process exists for the whole Proton launch. Compressing then stalls the + # launch, so the clock starts only once the game window has focus, and waits 20 s more. + bl_game_focused() { + [ -n "${BL_LAUNCH_DIR:-}" ] || return 1 + local id + id=$(sed -n 's/.*"focusedApp"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \ + "$BL_LAUNCH_DIR/agent/focus.json" 2>/dev/null | head -1) + [ -n "$id" ] && [ "$id" != 769 ] + } _bl_since=-1 _bl_packed=-1 while kill -0 $$ 2>/dev/null; do bl_zram_scan - if [ "$_bl_game" = 1 ]; then + if [ "$_bl_game" = 1 ] && bl_game_focused; then [ "$_bl_since" -ge 0 ] || _bl_since=$SECONDS if [ $((SECONDS - _bl_since)) -ge 20 ] && [ "${#_bl_pids[@]}" -gt 0 ] && { [ "$_bl_packed" -lt 0 ] || [ $((SECONDS - _bl_packed)) -ge 300 ]; }; then bl_zram_send 1 "compressing the client" _bl_packed=$SECONDS fi - elif [ "$_bl_since" -ge 0 ]; then + elif [ "$_bl_game" != 1 ]; then [ "$_bl_packed" -lt 0 ] || bl_zram_send 0 "game closed, reading the client back" _bl_since=-1 _bl_packed=-1 + else + _bl_since=-1 fi nap 5 done diff --git a/tools/tests/test_client_zram_focus.py b/tools/tests/test_client_zram_focus.py new file mode 100644 index 000000000..52c48b8be --- /dev/null +++ b/tools/tests/test_client_zram_focus.py @@ -0,0 +1,39 @@ +from pathlib import Path +import subprocess +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session' + + +class ClientZramFocusTest(unittest.TestCase): + def test_refocus_restarts_the_delay_and_game_exit_restores_a_packed_client(self): + source = SCRIPT.read_text() + start = source.index(' if [ "$_bl_game" = 1 ] && bl_game_focused; then') + stop = source.index(' nap 5', start) + body = source[start:stop] + harness = ''' +_bl_since=-1 _bl_packed=-1 +_bl_pids=(123) +bl_game_focused() { [ "$focused" = 1 ]; } +bl_zram_send() { echo "send:$1"; } +tick() { + SECONDS=$1 _bl_game=$2 focused=$3 +''' + body + ''' +} +tick 0 1 1 +tick 5 1 0 +tick 30 1 1 +echo "refocus:$_bl_packed" +tick 49 1 1 +tick 50 1 1 +tick 55 1 0 +tick 60 0 0 +''' + result = subprocess.run(['bash', '-c', harness], capture_output=True, text=True, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['refocus:-1', 'send:1', 'send:0'], result.stdout.splitlines()) + + +if __name__ == '__main__': + unittest.main() From 0a8358d11fe691b2fde2f4671cb84544de8accec Mon Sep 17 00:00:00 2001 From: 0x1Nawaf Date: Fri, 23 Oct 2026 15:52:35 +0000 Subject: [PATCH 2/4] fix: keep the gamescope cursor out of the zram change The session script had also picked up the cursor switch from the other branch. --- tools/linuxfs/overlay/usr/local/bin/droiddeck-session | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index df89589e5..02635d8a6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -227,9 +227,7 @@ if [ -z "${BL_INSIDE:-}" ]; then echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" fi fi - # Off unless Performance asks for it. Compositing the cursor here adds a second - # plane on the phone's compositor; a controller-driven cursor is the reason to turn it on. - if [ "$mode" = steam ] && [ "${BL_CURSOR_COMPOSITE:-0}" = 1 ]; then + if [ "$mode" = steam ]; then export gamescope_cursor_composite=2 fi args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") From 73e158ab3b0ce4de94734150d2cddb3084708f8d Mon Sep 17 00:00:00 2001 From: 0x1Nawaf Date: Sun, 25 Oct 2026 10:38:37 +0000 Subject: [PATCH 3/4] fix: compress only while the running game is focused --- .../overlay/usr/local/bin/droiddeck-session | 46 +++++++++++---- tools/tests/test_client_zram_focus.py | 59 ++++++++++++++++++- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 02635d8a6..f50dc9fab 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -447,14 +447,30 @@ case $mode in step "zRAM compression: on" ( bl_zram_scan() { - _bl_game=0 _bl_pids=() + _bl_game=0 _bl_pids=() _bl_apps= + local _d _c _args _name _a _id for _d in /proc/[0-9]*; do - # Under FEX the program is FEX's first argument. - { IFS= read -r -d '' _c && case ${_c##*/} in FEX|FEXInterpreter) IFS= read -r -d '' _c ;; esac; } \ - 2>/dev/null < "$_d/cmdline" || continue - case ${_c##*/} in - reaper) _bl_game=1 ;; - steam|steamwebhelper) _bl_pids+=("${_d#/proc/}") ;; + _args=() + # Under FEX the program is FEX's first argument. AppId= is its own argument. + while IFS= read -r -d '' _c; do _args+=("$_c"); done 2>/dev/null < "$_d/cmdline" || true + [ "${#_args[@]}" -gt 0 ] || continue + _name=${_args[0]} + if [ "${#_args[@]}" -gt 1 ]; then + case ${_name##*/} in FEX|FEXInterpreter) _name=${_args[1]} ;; esac + fi + case ${_name##*/} in + reaper) + for _a in "${_args[@]}"; do + case $_a in + AppId=769) ;; + AppId=[0-9]*) + _id=${_a#AppId=} + case $_id in *[!0-9]*) ;; *) _bl_game=1; _bl_apps="$_bl_apps $_id" ;; esac + ;; + esac + done + ;; + steam|steamwebhelper) _bl_pids+=("${_d##*/}") ;; esac done } @@ -475,14 +491,22 @@ case $mode in bl_zram_scan step "zRAM: done ($(bl_zram_usage))" } - # The game process exists for the whole Proton launch. Compressing then stalls the - # launch, so the clock starts only once the game window has focus, and waits 20 s more. + # Proton's reaper exists before the game window does, and compressing then stalls the + # launch. focusedApp uses the same 32-bit id as that reaper's AppId= (769 is Steam; a + # shortcut's CGameID keeps this id in its high half). The clock starts on a match, then + # waits 20 s more. Desktop Steam never writes focus.json, so the game process is enough. bl_game_focused() { - [ -n "${BL_LAUNCH_DIR:-}" ] || return 1 + if [ "${BL_DESKTOP:-}" = 1 ] || [ -z "${BL_LAUNCH_DIR:-}" ]; then + return 0 + fi local id id=$(sed -n 's/.*"focusedApp"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \ "$BL_LAUNCH_DIR/agent/focus.json" 2>/dev/null | head -1) - [ -n "$id" ] && [ "$id" != 769 ] + [ -n "$id" ] || return 1 + case " ${_bl_apps-} " in + *" $id "*) return 0 ;; + esac + return 1 } _bl_since=-1 _bl_packed=-1 while kill -0 $$ 2>/dev/null; do diff --git a/tools/tests/test_client_zram_focus.py b/tools/tests/test_client_zram_focus.py index 52c48b8be..cd3d7b5ab 100644 --- a/tools/tests/test_client_zram_focus.py +++ b/tools/tests/test_client_zram_focus.py @@ -1,11 +1,23 @@ from pathlib import Path import subprocess +import tempfile import unittest SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session' +def function_source(name): + source = SCRIPT.read_text() + start = source.index('%s() {' % name) + end = source.index('\n }', start) + return source[start:end + len('\n }')] + + +def run_bash(script, env=None): + return subprocess.run(['bash', '-c', script], capture_output=True, text=True, timeout=10, env=env) + + class ClientZramFocusTest(unittest.TestCase): def test_refocus_restarts_the_delay_and_game_exit_restores_a_packed_client(self): source = SCRIPT.read_text() @@ -30,10 +42,55 @@ tick 50 1 1 tick 55 1 0 tick 60 0 0 ''' - result = subprocess.run(['bash', '-c', harness], capture_output=True, text=True, timeout=10) + result = run_bash(harness) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(['refocus:-1', 'send:1', 'send:0'], result.stdout.splitlines()) + def test_scan_keeps_the_running_game_and_ignores_steams_own_reaper(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = Path(temp.name) + self.write_cmd(root, 10, ['/usr/lib/steam/reaper', 'SteamLaunch', 'AppId=769', '--']) + self.write_cmd(root, 20, ['/usr/bin/FEX', '/usr/lib/steam/reaper', 'SteamLaunch', 'AppId=1245620', '--']) + self.write_cmd(root, 30, ['/home/deck/.local/share/Steam/steamrtarm64/steam']) + scan = function_source('bl_zram_scan').replace('for _d in /proc/[0-9]*', 'for _d in "$FAKE"/[0-9]*') + result = run_bash('FAKE=%s\n%s\nbl_zram_scan\necho "game=$_bl_game"\necho "apps=${_bl_apps# }"\necho "pids=${_bl_pids[*]}"' % (root, scan)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['game=1', 'apps=1245620', 'pids=30'], result.stdout.splitlines()) + + def test_focus_must_be_the_running_game(self): + focus = function_source('bl_game_focused') + script = ''' +%s +check() { + BL_DESKTOP=$1 BL_LAUNCH_DIR=$2 _bl_apps=$3 + if bl_game_focused; then echo yes; else echo no; fi +} +check "" "$dir" " 413150" +check "" "$dir" " 1245620" +printf '%%s\\n' '{"focusedApp": 999}' > "$dir/agent/focus.json" +check "" "$dir" " 413150" +printf '%%s\\n' '{"focusedApp": 769}' > "$dir/agent/focus.json" +check "" "$dir" " 413150" +rm -f "$dir/agent/focus.json" +check 1 "$dir" " 413150" +check "" "" " 413150" +''' % focus + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + launch = Path(temp.name) + (launch / 'agent').mkdir() + (launch / 'agent' / 'focus.json').write_text('{"t": 1, "focusedApp": 413150}\n') + result = run_bash('dir=%s\n%s' % (launch, script)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['yes', 'no', 'no', 'no', 'yes', 'yes'], result.stdout.splitlines()) + + @staticmethod + def write_cmd(root, pid, args): + proc = root / str(pid) + proc.mkdir() + (proc / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in args) + b'\0') + if __name__ == '__main__': unittest.main() From d08afbd878e19c62df08589280be22f0c2751266 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:55:01 -0400 Subject: [PATCH 4/4] fix: restart compression delay for new games --- .../overlay/usr/local/bin/droiddeck-session | 7 ++-- tools/tests/test_client_zram_focus.py | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index b44d74599..0f99643ee 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -552,6 +552,7 @@ case $mode in } # Proton starts its reaper before the game window; compressing during launch stalls it. # Desktop Steam has no focus.json, so it still uses the game process as its trigger. + # A configured RAM cap can still force compression while a game is launching. bl_game_focused() { if [ "${BL_DESKTOP:-}" = 1 ] || [ -z "${BL_LAUNCH_DIR:-}" ]; then return 0 @@ -575,7 +576,7 @@ case $mode in bl_cap_due() { [ "$_bl_ram_cap" -gt 0 ] && { [ "$_bl_measured" -lt 0 ] || [ $((SECONDS - _bl_measured)) -ge 30 ]; } } - _bl_since=-1 _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 + _bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 while kill -0 $$ 2>/dev/null; do bl_zram_scan if bl_cap_due; then @@ -607,7 +608,9 @@ case $mode in fi fi if [ "$_bl_game" = 1 ] && bl_game_focused; then - [ "$_bl_since" -ge 0 ] || _bl_since=$SECONDS + if [ "$_bl_since" -lt 0 ] || [ "$_bl_since_apps" != "$_bl_apps" ]; then + _bl_since=$SECONDS _bl_since_apps=$_bl_apps + fi if [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ $((SECONDS - _bl_since)) -ge 20 ] && [ "${#_bl_pids[@]}" -gt 0 ] && { [ "$_bl_packed" -lt 0 ] || [ $((SECONDS - _bl_packed)) -ge 300 ]; }; then bl_zram_send 1 "compressing the client" client diff --git a/tools/tests/test_client_zram_focus.py b/tools/tests/test_client_zram_focus.py index c542f21a2..b35902e7f 100644 --- a/tools/tests/test_client_zram_focus.py +++ b/tools/tests/test_client_zram_focus.py @@ -25,7 +25,7 @@ class ClientZramFocusTest(unittest.TestCase): stop = source.index(' nap 5', start) body = source[start:stop] harness = ''' -_bl_since=-1 _bl_packed=-1 +_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_apps=" 413150" _bl_pids=(123) bl_game_focused() { [ "$focused" = 1 ]; } bl_over_cap() { return 1; } @@ -53,8 +53,10 @@ tick 60 0 0 start = source.index(' bl_zram_scan\n if bl_cap_due; then') stop = source.index(' nap 5', start) return ''' -_bl_since=-1 _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 +_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 +_bl_apps=" 413150" _bl_pids=(123) +step() { :; } bl_zram_scan() { :; } bl_game_focused() { [ "$focused" = 1 ]; } bl_cap_due() { [ "$cap_due" = 1 ]; } @@ -75,6 +77,32 @@ tick 30 0 0 0 0 self.assertEqual('', result.stderr) self.assertEqual('', result.stdout) + def test_a_new_game_restarts_the_compression_delay(self): + result = run_bash(self.monitor_harness() + ''' +tick 0 1 1 0 0 +_bl_apps=" 413150 1245620" +tick 15 1 1 0 0 +tick 20 1 1 0 0 +echo "starting:$_bl_packed" +tick 34 1 1 0 0 +tick 35 1 1 0 0 +''') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('', result.stderr) + self.assertEqual(['starting:-1', 'send:1:client'], result.stdout.splitlines()) + + def test_ram_cap_can_still_compress_an_unfocused_client(self): + result = run_bash(self.monitor_harness() + ''' +BL_ZRAM_CLIENT=0 BL_RAM_CAP_MB=1024 +bl_zram_usage() { _bl_rss=$((768 * 1024)); } +tick 0 1 0 1 1 +echo "cap:$_bl_packed:$_bl_since:$_bl_backoff" +tick 5 0 0 0 0 +''') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('', result.stderr) + self.assertEqual(['send:1:client', 'send:1:session', 'cap:0:-1:60', 'send:0:client'], result.stdout.splitlines()) + def test_game_exit_defers_restore_until_the_session_is_under_the_ram_cap(self): result = run_bash(self.monitor_harness() + ''' tick 0 1 1 0 0