diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index afc8de7..780121a 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py' diff --git a/README.md b/README.md index 97774de..51a3cf4 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,21 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. -If you want to keep the client open during matches, opt in to hiding its window instead of closing it: +For automatic client handling during a match, opt in with one switch: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. +It reads `lol-user-experience` from the installed League client's +`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the +client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or unreadable preferences it does +nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` +low-level switches remain available when the automatic switch is not set. See +[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) +for the measured effect and caveats. ## Testing diff --git a/docs/SETUP.md b/docs/SETUP.md index a83e8b7..2679892 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,12 +78,17 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. -`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` -and `Riot Client` X11 windows while a match runs, then showing them when the -game process ends. It keeps the client open and is mutually exclusive with -`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, -requires X11/Xwayland, and does not patch client binaries, CEF or the game. See -[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +`RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a +match. It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client +UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or preferences unreadable it does +nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. The feature is off by default, requires X11/Xwayland, and +does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2125d45..0c37ba5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,30 +192,39 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` -### Keep the client open but hide it during a match +### Automatic client handling during a match -`LeagueClientUx` can consume a noticeable amount of CPU while the client stays -open during a match. An opt-in alternative to closing it hides the managed -client window instead: +A single opt-in switch reads the client's own preferences and chooses the mode: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The launcher detects when a match starts, unmaps the `LeagueClientUx` and -`Riot Client` X11 windows, and maps them again when the game process ends. The -client is not closed, the League backend and Riot client keep running, and the -game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence -when both flags are set. +It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: -The helper is off by default and window-management only: it does not patch -client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix -through the loader's `__mldr_sockpath`, and fails closed when ownership, proc -state, environment or window information cannot be read. It requires an X11 or -Xwayland desktop; on a native Wayland window manager without Xwayland it cannot -find the windows. +| Client preference | Launcher action | +|---|---| +| `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | -On a CPU-bound host the effect appears after CEF has been hidden for a few +The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps +them again when the game process ends. The client is not closed, the League +backend and Riot client keep running, and the game window is never hidden. The +close path uses the UI-close helper described above. + +The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and +`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. All modes are off by default. The helpers are +window-management only: they do not patch client binaries, CEF, anti-cheat or +DRM. They attribute the game to this prefix through the loader's +`__mldr_sockpath`, and fail closed when ownership, proc state, environment or +window information cannot be read. The hide helper requires an X11 or Xwayland +desktop; on a native Wayland window manager without Xwayland it cannot find the +windows. + +On a CPU-bound host the hide effect appears after CEF has been hidden for a few seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding `LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from 120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the @@ -224,16 +233,9 @@ a game that is GPU-bound, a client with no active UI work, or a different desktop may show less benefit. The hidden window can also need a moment to repaint after it is shown. -`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client -chat during the match is not needed. Configure one of the two, not both: - -```sh -# Keep the client open, hide its windows while playing -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh - -# Or close the client UI while playing -RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh -``` +With client optimizations enabled and close-during-game enabled, the close +helper remains the more aggressive option when in-client chat during the match +is not needed. Diagnose the match detector without changing any window: diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py new file mode 100644 index 0000000..6477571 --- /dev/null +++ b/tests/test_client_in_game_tricks.py @@ -0,0 +1,69 @@ +"""Select the automatic client-in-game mode from LCULocalPreferences.yaml.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py') +tricks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tricks) + + +class ClientInGameTricksTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml' + + def write(self, optimization=None, close=None): + lines = ['lol-user-experience:', ' data:'] + if optimization is not None: + lines.append(f' potatoModeEnabled: {optimization}') + if close is not None: + lines.append(f' closeLeagueClientDuringGame: {close}') + self.path.write_text('\n'.join(lines) + '\n') + + def test_close_when_both_preferences_are_true(self): + self.write(True, True) + self.assertEqual(tricks.mode(self.path), 'close') + + def test_hide_when_optimizations_are_on_and_close_is_false(self): + self.write(True, False) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_hide_when_close_preference_is_missing(self): + self.write(True) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_none_when_optimizations_are_off(self): + self.write(False, True) + self.assertEqual(tricks.mode(self.path), 'none') + + def test_none_when_optimizations_are_missing_or_file_is_unreadable(self): + self.write(None, True) + self.assertEqual(tricks.mode(self.path), 'none') + self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none') + + def test_last_duplicate_value_wins_and_quotes_are_accepted(self): + self.path.write_text( + 'potatoModeEnabled: false\n' + 'closeLeagueClientDuringGame: false\n' + 'potatoModeEnabled: "true"\n' + 'closeLeagueClientDuringGame: "true"\n') + self.assertEqual(tricks.mode(self.path), 'close') + + def test_main_prints_mode(self): + self.write(True, False) + output = io.StringIO() + with patch('sys.stdout', output): + self.assertEqual(tricks.main([str(self.path)]), 0) + self.assertEqual(output.getvalue().strip(), 'hide') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 87f4e05..95be689 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -5,7 +5,7 @@ import os from pathlib import Path import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location( @@ -217,6 +217,24 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_master_tricks_switch_enables_hide(self): + class FakeX11: + def close(self): + pass + + watcher = Mock() + with patch.dict(os.environ, { + 'RCLOL_CLIENT_IN_GAME_TRICKS': '1', + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'X11', return_value=FakeX11()), \ + patch.object(hide_in_game, 'Watcher', return_value=watcher): + self.assertEqual(hide_in_game.main([]), 0) + watcher.run.assert_called_once_with() + watcher.restore.assert_called_once_with() + if __name__ == '__main__': unittest.main() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py new file mode 100755 index 0000000..bdc8d6b --- /dev/null +++ b/tools/client-in-game-tricks.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Resolve the client-in-game mode from League's local preferences. + +The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads +`lol-user-experience` from LCULocalPreferences.yaml: + +- potatoModeEnabled must be true (client optimizations enabled); +- closeLeagueClientDuringGame true -> close the client UI while a match runs; +- closeLeagueClientDuringGame false -> hide the client windows instead; +- missing/unreadable preferences -> no action (none). + +This helper only selects a mode. It does not change preferences or files. +""" + +import re +import sys +from pathlib import Path + +CLOSE_KEY = 'closeLeagueClientDuringGame' +OPTIMIZATION_KEY = 'potatoModeEnabled' +TRUE_VALUES = {'true', 'yes', '1'} +FALSE_VALUES = {'false', 'no', '0'} + + +def read_bool(path, key): + """Return True/False for the last value, or None when absent/unreadable.""" + try: + lines = Path(path).read_text().splitlines() + except (OSError, UnicodeError): + return None + value = None + for line in lines: + match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) + if match: + value = match.group(1).strip('"\'').lower() + if value in TRUE_VALUES: + return True + if value in FALSE_VALUES: + return False + return None + + +def mode(path): + if read_bool(path, OPTIMIZATION_KEY) is not True: + return 'none' + return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + path = argv[0] if argv else '' + print(mode(path)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 91264d8..68ad0b9 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Opt-in: hide the League client UI while a match is running. -`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with -`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for -`LeagueClientUx` and `Riot Client`, and shows them again when the game process -exits. The Riot client, League backend, CEF helpers and game keep running. +The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when +the client's own close-during-game preference is false. The low-level +`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It +hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows +them again when the game process exits. The Riot client, League backend, CEF +helpers and game keep running. This is launcher/window management only. It does not patch client binaries, CEF, anti-cheat or DRM, and it never hides the game window. It is off by @@ -332,13 +334,14 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1' + if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': - print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', - flush=True) + if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or ' + 'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True) return 0 prefix = target_prefix() if prefix is None: diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 88dc083..7d76a6d 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,23 +12,30 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi -# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game -# and leaves the client the backend starts when the match ends as the only CEF instance; -# a second instance on the same profile never paints its window, which is the black -# window seen with Riot's own "Close client during game" option. Off by default. -if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then +# Opt-in automatic client-in-game handling. This single switch reads the +# client's own lol-user-experience preferences: close the UI when the user +# selected "close client during game", otherwise hide the client windows. +# It does nothing unless potatoModeEnabled (client optimizations) is true. +if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 + ;; + esac +elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & -fi -# Opt-in alternative for users who keep the client open during a match: hide -# the managed client windows instead of closing the UI. It never touches the -# League backend, the Riot client or the game window; it restores the window -# when the game process ends. Close and hide are mutually exclusive. -if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 - else - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - fi +elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver.