feat(launcher): hide the League client UI while a match runs

Add an opt-in alternative to closing LeagueClientUx: hide the managed
LeagueClientUx and Riot Client X11 windows while the game process runs and
show them again when it exits. The client stays open; the game window is
never touched.

Detection is attributed to the launcher prefix through __mldr_sockpath and
fails closed when proc, ownership or window state is unreadable. The helper
requires X11/Xwayland, restores the window on end, signal or error, and is
mutually exclusive with RCLOL_CLOSE_CLIENT_IN_GAME=1.

Document the flag, compliance scope and limits. A local 30-second test on an
i5-3470/RX 550 reduced summed client/CEF CPU from 120.7% to 93.4%; this is a
CPU-bound-host measurement, not a universal FPS claim.

No client binaries, CEF, assets or anti-cheat are modified.
This commit is contained in:
3AvisD committed 2026-10-22 12:15:35 -06:00
1 parent a4da79011f
commit 415aa7a949
7 files changed
+537

No files matched your search

+1
View File
@@ -31,3 +31,4 @@ jobs:
- run: python -m unittest discover -s tests -p 'test_metal_device_info.py'
- 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'
+8
View File
@@ -109,6 +109,14 @@ 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:
```sh
RCLOL_HIDE_CLIENT_IN_GAME=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.
## Testing
Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers.
+8
View File
@@ -78,6 +78,14 @@ 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)
for the measured CPU effect and limitations.
`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before
the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`,
GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`),
+49
View File
@@ -192,6 +192,55 @@ 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
`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:
```sh
RCLOL_HIDE_CLIENT_IN_GAME=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.
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.
On a CPU-bound host the 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
window returned it to 119.9%. This is not a universal FPS or percentage claim:
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
```
Diagnose the match detector without changing any window:
```sh
python3 tools/hide-league-client-in-game.py --once
```
### Riot reports NO INTERNET FOUND while the host is online
Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which
+165
View File
@@ -0,0 +1,165 @@
"""Exercise the in-match client window hide with fake /proc entries and X11."""
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(
'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py')
hide_in_game = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hide_in_game)
GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends'
MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx'
RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices'
class FakeX11:
def __init__(self):
self.unmapped = []
self.shown = []
def unmap(self, window):
self.unmapped.append(window)
def show(self, window):
self.shown.append(window)
class HideClientInGameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / '.darling'
self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)})
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
def environment(self, prefix):
return b'%s=%s\0' % (
hide_in_game.SOCK_VARIABLE,
os.fsencode(prefix / hide_in_game.SOCK_NAME),
)
def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'cmdline').write_bytes(
executable.encode() + b'\0' +
b''.join(arg.encode() + b'\0' for arg in args))
if environ is None:
environ = self.environment(self.prefix if prefix == 'own' else prefix)
(folder / 'environ').write_bytes(environ)
(folder / 'stat').write_text(f'{pid} (game) S')
return folder
def test_game_running_for_own_prefix(self):
self.process(123)
self.assertTrue(hide_in_game.game_running(self.prefix, self.proc))
def test_no_game_is_false(self):
self.process(123, executable=MAIN)
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_of_another_prefix_is_false(self):
self.process(123, prefix=self.root / 'other')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_without_socket_is_false(self):
self.process(123, environ=b'')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_unreadable_environment_is_unknown(self):
folder = self.process(123)
original = Path.read_bytes
def read(path):
if path.name == 'environ':
raise PermissionError()
return original(path)
with patch.object(Path, 'read_bytes', read):
self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc))
def test_target_windows_ignores_game_class(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20', '0x30']), \
patch.object(hide_in_game, 'window_class',
side_effect=[
'wm_class(string) = "leagueclientux"',
'wm_class(string) = "leagueoflegends"',
'wm_class(string) = "riot client"',
]):
self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30])
def test_hides_then_shows_on_match_end(self):
states = iter([True, True, False])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda: [0xabc],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=3)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('hidden 0xabc', output.getvalue())
self.assertIn('the match ended', output.getvalue())
self.assertIn('shown 0xabc', output.getvalue())
def test_unreadable_state_restores_the_window(self):
states = iter([True, None])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda: [0xdef],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=2)
self.assertEqual(x11.unmapped, [0xdef])
self.assertEqual(x11.shown, [0xdef])
self.assertIn('unreadable /proc state', output.getvalue())
def test_no_game_does_not_touch_windows(self):
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda: [0xabc],
state_source=lambda _prefix: False,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher.run(max_steps=1)
self.assertEqual(x11.unmapped, [])
self.assertEqual(x11.shown, [])
def test_main_is_disabled_by_default(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_close_mode_takes_precedence(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '1',
'RCLOL_CLOSE_CLIENT_IN_GAME': '1',
}):
self.assertEqual(hide_in_game.main([]), 0)
if __name__ == '__main__':
unittest.main()
+295
View File
@@ -0,0 +1,295 @@
#!/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.
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
default, fails closed, and is intended for X11/Xwayland desktops.
The game is attributed to this prefix through the loader's
`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc,
ownership or window information cannot be read, no window is touched.
Diagnose one observation with:
python3 tools/hide-league-client-in-game.py --once
Enable it for a launcher run with:
RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh
"""
import argparse
import ctypes
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
GAME_NAME = 'LeagueofLegends'
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
POLL_SECONDS = 2.0
TARGET_CLASSES = ('leagueclientux', 'riot client')
def target_prefix():
"""The prefix this launcher owns, or None when it cannot be determined."""
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def process_state(proc):
try:
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
except (OSError, IndexError):
return None
def command_names(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [
os.path.basename(part.decode(errors='replace'))
for part in raw.split(b'\0')
if part
]
def game_running(prefix, proc_root=Path('/proc')):
"""True/False for a game of this prefix; None when /proc is unreliable.
The game executable is matched by its exact file name. The process must
carry the loader socket of this prefix, so a game from another install is
never considered. Any unreadable candidate makes the result None (fail
closed).
"""
owner = (prefix / SOCK_NAME).parent.resolve()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit() or process_state(proc) in ('Z', None):
continue
try:
if proc.stat().st_uid != os.getuid():
continue
if GAME_NAME not in command_names(proc):
continue
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
socket = environment.get(SOCK_VARIABLE)
if socket is None:
continue
if Path(os.fsdecode(socket)).parent.resolve() != owner:
continue
return True
except FileNotFoundError:
if proc.exists():
return None
continue
except (OSError, ValueError):
return None
return False
def xprop_root():
try:
listing = subprocess.check_output(
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return []
return re.findall(r'0x[0-9a-fA-F]+', listing)
def window_class(window):
try:
prop = subprocess.check_output(
['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return ''
return prop.lower()
def target_windows():
"""Managed client windows only; the game window is never selected."""
found = []
for window in xprop_root():
classes = window_class(window)
if any(name in classes for name in TARGET_CLASSES):
found.append(int(window, 16))
return sorted(set(found))
class X11:
def __init__(self):
self.lib = ctypes.CDLL('libX11.so.6')
self.lib.XOpenDisplay.restype = ctypes.c_void_p
self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p]
error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
self._error_handler = error_type(lambda _display, _event: 0)
self.lib.XSetErrorHandler.argtypes = [error_type]
self.lib.XSetErrorHandler.restype = ctypes.c_void_p
self.lib.XSetErrorHandler(self._error_handler)
self.display = self.lib.XOpenDisplay(None)
if not self.display:
raise RuntimeError('cannot open DISPLAY')
def unmap(self, window):
self.lib.XUnmapWindow(self.display, window)
self.lib.XSync(self.display, 0)
def show(self, window):
self.lib.XMapRaised(self.display, window)
self.lib.XSync(self.display, 0)
def close(self):
self.lib.XCloseDisplay(self.display)
class Watcher:
"""Hide while a match is running, restore on end, uncertainty or exit."""
def __init__(self, prefix, x11, window_source=target_windows,
state_source=game_running, sleep=time.sleep, out=sys.stdout):
self.prefix = prefix
self.x11 = x11
self.window_source = window_source
self.state_source = state_source
self.sleep = sleep
self.out = out
self.hidden = set()
self.reported_no_window = False
def _show_all(self, reason):
if not self.hidden:
return
print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True)
for window in sorted(self.hidden):
try:
self.x11.show(window)
print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True)
except Exception as error:
print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}',
file=sys.stderr, flush=True)
self.hidden.clear()
def _hide_new(self):
windows = self.window_source()
if not windows and not self.hidden and not self.reported_no_window:
print('hide-league-client-in-game: no managed client window; not hiding',
file=self.out, flush=True)
self.reported_no_window = True
for window in windows:
if window in self.hidden:
continue
try:
self.x11.unmap(window)
self.hidden.add(window)
print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True)
except Exception as error:
print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
file=sys.stderr, flush=True)
def run(self, max_steps=None):
steps = 0
while True:
state = self.state_source(self.prefix)
if state is None:
self._show_all('unreadable /proc state')
elif state:
if not self.hidden and self.reported_no_window:
self.reported_no_window = False
self._hide_new()
else:
self._show_all('the match ended')
self.reported_no_window = False
steps += 1
if max_steps is not None and steps >= max_steps:
return 0
self.sleep(POLL_SECONDS)
def restore(self):
self._show_all('exiting')
def main(argv=None):
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--detach', action='store_true',
help='leave the launcher session so a terminal hang-up cannot end it')
parser.add_argument('--once', action='store_true',
help='one game observation and exit (no window changes)')
args = parser.parse_args(argv)
if 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 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)
return 0
prefix = target_prefix()
if prefix is None:
print('hide-league-client-in-game: unusable DPREFIX: no action',
file=sys.stderr, flush=True)
return 1
if args.once:
state = game_running(prefix)
print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}',
flush=True)
return 0
if args.detach:
try:
os.setsid()
except OSError:
pass
try:
x11 = X11()
except Exception as error:
print(f'hide-league-client-in-game: cannot open X11: {error}',
file=sys.stderr, flush=True)
return 1
watcher = Watcher(prefix, x11)
def stop(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print('hide-league-client-in-game: watching %s for a running match; '
'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True)
try:
watcher.run()
except KeyboardInterrupt:
pass
finally:
watcher.restore()
x11.close()
return 0
if __name__ == '__main__':
sys.exit(main())
+11
View File
@@ -19,6 +19,17 @@ fi
if [ "${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
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.
export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"