Merge remote-tracking branch 'origin/main' into pr-fix-121

# Conflicts:
#	.github/workflows/source-check.yml
#	NOTICE.md
#	README.md
#	docs/COMPLIANCE.md
#	docs/PLATFORM_IDENTITY.md
#	docs/SETUP.md
#	docs/TROUBLESHOOTING.md
#	patches/cocotron.patch
#	patches/metal--deps--indium.patch
#	tools/exec-guest.py
#	tools/run-riot-client.sh
This commit is contained in:
lucannez64 committed 2026-10-25 19:11:54 +01:00
commit 65d4354667
22 files changed
+2465 -26

No files matched your search

+9
View File
@@ -16,6 +16,10 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Require C++ regression compiler
run: |
command -v c++
c++ --version
- run: python scripts/validate.py
- run: python tools/riot-input-offline-check.py
- run: python tools/riot-launcher-offline-check.py
@@ -29,5 +33,10 @@ jobs:
- run: python -m unittest discover -s tests -p 'test_urlsession_runner.py'
- run: python -m unittest discover -s tests -p 'test_system_profiler.py'
- run: python -m unittest discover -s tests -p 'test_metal_device_info.py'
- run: python -m unittest discover -s tests -p 'test_indium_address_shadow.py' -v
- 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_recover_stale_client.py'
- run: python -m unittest discover -s tests -p 'test_clean_stale_helpers.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'
+67
View File
@@ -0,0 +1,67 @@
# Contributing
Thank you for improving `darling-lol`. The project is an unofficial
interoperability effort; contributions must keep it in that scope.
## Before you start
- Read [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md) and
[`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md).
- Do not add Riot client files, game binaries, game assets, logos, screenshots,
gameplay audio/video, account data, tokens or credentials.
- Do not add DRM/anti-cheat/authentication circumvention, telemetry spoofing,
credential handling, or gameplay automation. Changes that present the project
as an evasion tool are out of scope.
- Keep existing copyright, license and attribution notices. New third-party code
or assets need a license and provenance note in the pull request.
## License of contributions
- Original integration tooling (scripts, tools, tests, docs and project artwork)
is licensed under **GPL-3.0-only**.
- Patches to upstream components are licensed under the license of the upstream
file(s) they modify. If a patch touches files under different licenses, each
hunk keeps the license of its target file.
- By submitting a contribution, you confirm that you have the right to submit it
and agree to license it under the applicable terms above.
## Developer Certificate of Origin
Sign off every commit:
```sh
git commit -s -m "area: short description"
```
The sign-off certifies the Developer Certificate of Origin 1.1
(<https://developercertificate.org/>): you created the contribution or have the
right to submit it under the project's license, and you understand that the
contribution and its sign-off are public and may be redistributed.
## Checks
Run the source audit and the relevant tests before opening a pull request:
```sh
python3 scripts/validate.py
python3 -m unittest discover -s tests -p 'test_*.py'
```
The audit checks tracked source, rendered Python/shell, pinned revision format,
patch existence and patch parsing. It does not check patch applicability to the
pinned revisions. Do not bypass it; fix the source or document the exception in
the pull request.
## Pull requests
- Keep changes focused and describe the compatibility problem and the fix.
- Mention any change that touches platform identity, sandbox/AMFI probes,
anti-cheat or account risk. Do not describe it as stealth, evasion, spoofing
or an anti-cheat bypass.
- If you change a patch, run `python3 scripts/prepare.py --check-only` against
an existing pinned source checkout to verify that it applies to the pinned
revision.
- If you change a user-facing variable, keep the old name as a deprecated alias
for at least one release.
This is not legal advice. See [`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md).
+5 -1
View File
@@ -6,10 +6,14 @@ The manifest points to public upstream source repositories. This repository incl
Some compatibility tools refer to public API names and constants of the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly.
## Historical material
Earlier revisions of this repository included a third-party game logo and an embedded gameplay clip. Both were removed from the current tree; the current tree uses an original project mark and contains no gameplay media. Git history and third-party forks may still contain those earlier revisions. They are not part of the current project, are not licensed by this project, and should not be reused.
## Trademarks
League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc.
This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or
sponsored by Riot Games.
See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations.
See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations, and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's practices on interoperability, copyright, access controls and trademarks. Neither document is legal advice.
+51 -5
View File
@@ -36,7 +36,7 @@ This is a development snapshot, not a finished launcher. Riot and League clients
- Reusable server receive buffers and Darwin allocator hints translated to Linux lazy reclamation.
- Build/install/launch wrappers, atomic incremental installers and standalone regression probes.
Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot.
Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers; the opt-in window-management helpers described below are off by default and do not patch client binaries. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot.
## Requirements
@@ -109,9 +109,52 @@ 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 the game or Riot client was closed but `darlingserver` is still running, opt in to partial recovery before the next launch:
```sh
RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh
```
It stops only a windowless Riot/League/CEF tree at least 60 seconds old,
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then
starts the product normally. It recognizes `LeagueClientUx`, `Riot Client` and
`RiotClientServices` windows, does nothing while a game or managed client window
is present, and fails closed when X11 properties cannot be read. See
[troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window).
If repeated launches leave several `close-league-client-in-game.py` watchers,
opt in to removing the orphaned ones before the new helper starts:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh
```
It only removes helpers of this prefix whose parent is `init`/`systemd` (the
launcher that started them has exited). A helper of a live launcher, another
prefix, the game, Riot/League/CEF, `darlingserver` and the runtime are never
touched. See [troubleshooting](docs/TROUBLESHOOTING.md#multiple-client-in-game-helpers).
For automatic client handling during a match, opt in with one switch:
```sh
RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
```
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, and hides the
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and
`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the
automatic switch is not set. The helper exits after ten minutes without a
client or match once it has seen one. See
[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured 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.
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. The project includes standalone compatibility probes and the opt-in host-side window-management helpers described above, but no client injection hooks, client API automation or input automation; the normal launcher enables none of them.
FPS counters, game presentation requests and physical display refresh are different measurements. Record average and p99 frame times, the active scene, graphics settings, charger/power state and whether the game stayed focused. See [current status](docs/STATUS.md).
@@ -136,12 +179,16 @@ scripts/ portable entry points
upstream/manifest.json exact base revisions and patch ownership
patches/ current source changes, one patch per repository
tools/ runtime templates, installers and standalone regression probes
docs/ setup, testing, status and licensing notes
docs/ setup, testing, status, compliance and interoperability notes
build/ local sources, outputs and rendered tools (ignored)
```
Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files.
## Interoperability and compliance
This project is an unofficial interoperability effort. It distributes source patches for Darling and original tooling, not the Riot client, game code, assets or accounts. The normal launcher does not patch the client, game, CEF or anti-cheat. Users obtain the client from Riot and are responsible for complying with Riot's terms; account restrictions are possible. See [interoperability and compliance scope](docs/INTEROPERABILITY.md) and [compliance guidance](docs/COMPLIANCE.md). Neither document is legal advice.
## Trademarks
League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc.
@@ -149,8 +196,7 @@ This project is an unofficial compatibility effort and is not affiliated with, e
sponsored by Riot Games. No game client, game assets, or account data are distributed in this
repository.
See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and
review expectations.
See [compliance and terminology guidance](docs/COMPLIANCE.md) and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's scope and review expectations.
## Donations
+31 -4
View File
@@ -2,14 +2,21 @@
This project is an unofficial compatibility layer for running the macOS League of Legends
client on Linux through Darling. It is not affiliated with, endorsed by, or sponsored by Riot
Games, Inc.
Games, Inc. See [interoperability and compliance scope](INTEROPERABILITY.md) for the project's
practices on interoperability, copyright, access controls and trademarks. Neither document is
legal advice.
## Scope
- The project is about compatibility: making the client run, render and behave correctly on
Linux.
- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, or other platform
security controls, and it does not distribute a game client, game assets, or account data.
- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, authentication, or other
platform security controls, and it does not distribute a game client, game assets, or account
data.
- It does not forge authentication tokens or server-side integrity attestations. The synthetic
platform identity is local compatibility metadata, not an Apple-issued identifier or a Riot
credential. This repository does not pass it as Riot authentication data; the official
client's later handling of guest-visible values is outside this repository's scope.
- Do not describe or present the project as a tool for evading detection, preventing bans,
bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned.
@@ -27,6 +34,19 @@ Technical facts can be described precisely without framing the work as evasion.
touches a client check or a platform identity, say what it does and why it is needed for
compatibility.
## Known local limitations
- Darling has no macOS MAC/Seatbelt/AMFI framework. The compatibility layer reports the local
`___mac_syscall` probe as satisfied so Chromium/CEF can start. This does **not** implement a
sandbox and does not make the guest secure. Treat the guest as unsandboxed and do not run
untrusted content. It does not interact with Riot's servers or bypass a Riot control.
- Platform-identity values generated by `tools/configure-darling-platform.py` are synthetic and
local. They are not Apple-issued identifiers, credentials or authentication tokens; do not use
them with Apple services or to impersonate real hardware.
- The opt-in hide helper manages local client windows while a match runs. The opt-in close helper
terminates eligible League client UI processes. Neither helper patches client binaries, CEF,
anti-cheat or DRM, and both helpers are off by default.
## Assets and third-party material
- Do not add or link third-party game assets: logos, icons, fonts, screenshots, gameplay
@@ -34,6 +54,9 @@ compatibility.
- Project icons and artwork must be original. Forks must use their own name and icon; see the
trademark notice in `README.md` and `NOTICE.md`.
- Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries.
- Keep upstream copyright, license and attribution notices.
- Donations are voluntary and support the free tooling. They do not sell the game, a license,
access or priority.
## Pull requests and issues
@@ -43,7 +66,11 @@ compatibility.
scope accurate, not about hiding technical details.
- Contributions that add third-party assets or present the project as a DRM/anti-cheat
circumvention tool are out of scope for this repository.
- Sign off commits under the Developer Certificate of Origin; see
[`CONTRIBUTING.md`](../CONTRIBUTING.md). By contributing, you confirm that you have the right
to submit the work and license it under the project's terms.
## Note
This document describes project scope and review expectations. It is not legal advice.
This document describes project scope and review expectations. It is not legal advice. See
[interoperability and compliance scope](INTEROPERABILITY.md) and [`NOTICE.md`](../NOTICE.md).
+176
View File
@@ -0,0 +1,176 @@
# Interoperability and compliance scope
> This document explains how the project stays within interoperability and
> responsible-use practices. It is **not legal advice**. Software, contract and
> platform rules are fact-specific and differ by country. Consult qualified
> counsel before distributing, modifying or using this project.
## 1. Purpose
`darling-lol` (<https://github.com/lucannez64/darling-lol/>) is an unofficial
interoperability effort. It applies source patches to
[Darling](https://github.com/darlinghq/darling), a free-software macOS
compatibility layer, so that the official macOS Riot client can run on
x86-64 Linux. The user obtains the client from Riot and signs in through Riot's
own client and services.
The project is not a game, a game server, an account service, a launcher for
pirated content, or a substitute for Riot's platform. It does not provide Riot
accounts, authentication, matchmaking, payments, telemetry or game servers.
## 2. What we distribute and what we do not
We keep the project on the interoperability side by distributing only:
- source patches for Darling, a free-software compatibility layer;
- original integration, build and test tooling;
- project artwork;
- documentation and license notices.
We do **not** distribute, bundle, mirror or sublicense:
- the Riot client or game binaries;
- game assets, art, audio, video, fonts or data;
- account databases, tokens or credentials;
- Riot installers or installers for pirated content.
The user obtains the client from Riot and installs it through Riot's own
installer. The project does not provide accounts, authentication, matchmaking,
payments, telemetry or game servers, and it does not sell the game, a license,
access or priority.
## 3. Platform identity
Darling needs macOS-like platform metadata. `tools/configure-darling-platform.py`
can generate synthetic serial numbers, UUIDs and related fields, or import
values supplied by the user, so that the guest environment is internally
consistent.
These values are **local compatibility metadata**:
- they are not Apple-issued identifiers, credentials or authentication tokens;
- they are not used with Apple services and should not be used to impersonate
real hardware;
- this repository does not pass them to Riot as authentication data. The values
are exposed to guest applications through IOKit, and the official client's
later handling of them is outside this repository's scope. The official client
authenticates through Riot's own flow.
See `docs/PLATFORM_IDENTITY.md` for the schema, validation rules and warnings.
## 4. Local sandbox/AMFI probe
Darling has no macOS MAC, Seatbelt or AMFI framework. Chromium/CEF, which the
Riot client embeds, probes `___mac_syscall`. An `ENOSYS` response is treated as
fatal and prevents CEF helper processes from starting. The compatibility layer
therefore reports the local probe as satisfied. The relevant code and comments
are in `patches/xnu.patch` (`mac_syscall.c`).
This compatibility shim:
- does not implement a sandbox and does not make the guest secure;
- does not interact with Riot's servers or alter Riot's account, payment or
server-side integrity flows;
- does not grant access to Riot's content or services.
Treat the guest as **unsandboxed**. Do not run untrusted content inside it. This
is a local compatibility limitation, not a claim of security. See
`docs/COMPLIANCE.md` for the project's terminology and scope guidance.
## 5. Copyright and licenses
We keep the project's copyright and licensing position clear:
- the game, client, code and assets remain Riot's; we do not reproduce, store,
distribute or commercialise them;
- we distribute patches to free-software Darling and original tooling, not a
copy of the game or the upstream runtime;
- each patch keeps the license and notices of the upstream file it modifies;
original tooling is GPL-3.0-only;
- we keep the upstream license and attribution notices in the patched files and
describe the project's licensing position in `NOTICE.md`;
- we do not remove copyright or license notices from patched files;
- we review good-faith licensing and removal requests.
We do not sell the game, a license, access or priority. Donations are voluntary
and support development of the free tooling; they do not purchase the game or
any Riot service.
## 6. Access, authentication and integrity controls
The project is designed for compatibility, not for defeating access,
authentication, payment or integrity controls:
- we do not provide tools or features whose purpose is to defeat those controls;
- we do not forge authentication tokens, session data, account data or
server-side integrity attestations;
- we do not proxy or emulate Riot accounts, matchmaking, payments, telemetry or
game servers;
- we do not patch Riot's client or game binaries, CEF or anti-cheat;
- we do not automate gameplay input or provide cheats;
- the opt-in hide helper manages local client windows while a match runs; the
opt-in close helper terminates eligible League client UI processes. Both are
off by default and do not change client binaries, CEF or anti-cheat.
The synthetic platform identity in §3 is local metadata; it is not a credential.
This repository does not pass it as Riot authentication data.
## 7. User agreements and account risk
Use of the project is at your own risk. You must:
- own a legitimate Riot account and obtain the client from Riot;
- comply with Riot's terms and user agreement;
- not cheat, exploit, automate gameplay or use the project to gain an unfair
advantage;
- accept that Riot does not support this environment and may suspend or ban
accounts, including accounts used only for testing. Do not use a main or
ranked account.
The project does not authorise any violation of Riot's terms. If you do not
agree, do not use the project. Account restrictions are a real risk and are
documented in the README announcement.
## 8. Trademarks and presentation
League of Legends and Riot Games are trademarks or registered trademarks of Riot
Games, Inc. The names are used only to describe the compatibility target
(nominative use). The project:
- does not use Riot logos, game art or other Riot brand assets;
- uses an original project mark;
- is not affiliated, endorsed or sponsored by Riot Games;
- asks forks to use their own name and icon and not to imply endorsement.
See `NOTICE.md` and `docs/COMPLIANCE.md`.
## 9. Historical material
Earlier revisions of the repository included a third-party game logo and an
embedded gameplay clip. They were removed from the current tree; the current
tree uses an original project mark and contains no gameplay media. Git history
and third-party forks may still contain those earlier revisions. They are not
part of the current project, are not licensed by this project, and should not be
reused. The project will review good-faith removal requests.
## 10. Notices and contact
For a licensing, trademark or interoperability question, contact the maintainer
using the address in `README.md`, or open an issue in the repository. Include:
- the repository URL, file or commit at issue;
- the right or license you assert;
- the action you request;
- a way to contact you.
For a formal copyright notice, use the hosting platform's copyright notice
process. This document is not a designation of an agent for any platform and
does not replace that process.
## 11. No legal advice and no warranty
This document is a technical and scope statement, not legal advice. The project
is provided "as is", without warranty of any kind. Nothing here guarantees that
a particular use is lawful in a particular country, that Riot will not restrict
an account, or that a court or regulator will accept the project's position. If
you need legal certainty, consult a lawyer qualified in your country.
+8
View File
@@ -9,6 +9,14 @@ does not issue authentic Apple identities or change CPU, GPU, or security
metadata. The overrides are for application compatibility; they are not intended
to bypass anti-cheat, telemetry, licensing, or platform security controls.
> **Synthetic, local metadata.** These values are not Apple-issued identifiers,
> credentials or authentication tokens. Do not use them with Apple services or
> to impersonate real hardware. This repository does not pass them to Riot as
> authentication data; the values are exposed to guest applications through
> IOKit, and the official client's later handling of them is outside this
> repository's scope. See the
> [interoperability and compliance scope](INTEROPERABILITY.md).
Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing
`darling-smbios.conf` without changing the selected prefix:
+32
View File
@@ -78,6 +78,38 @@ 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_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before
launch. If `darlingserver` is alive, no game is running, no managed client
window is visible and stale Riot/League/CEF processes at least 60 seconds old
remain, the launcher stops only that client tree and reuses the runtime. It
recognizes `LeagueClientUx`, `Riot Client` and `RiotClientServices` windows and
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`; if X11/Xwayland
properties cannot be inspected it does nothing. See
[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window).
`RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts.
It removes `close-league-client-in-game.py` and
`hide-league-client-in-game.py` processes of this prefix whose parent is
`init`/`systemd`, which means the launcher that started them has exited. A
helper of a live launcher, another prefix, the game, Riot/League/CEF and
`darlingserver` are preserved. It fails closed when a command line,
environment, stat entry or parent cannot be read. See
[troubleshooting](TROUBLESHOOTING.md#multiple-client-in-game-helpers).
`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`, and hides the managed
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. 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. The hide helper exits after ten
minutes without a client or match once it has seen one. 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
the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`,
GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`),
+89
View File
@@ -149,6 +149,41 @@ ownership or window information cannot be read. A hidden background client or
download is restarted when recovery is enabled. Other desktop environments are
not supported by this helper; recovery is off by default.
For a live Darling runtime with no visible client window but stale Riot/League/
CEF processes at least 60 seconds old, `RCLOL_RECOVER_STALE_CLIENT=1` performs a
partial recovery before the next launch. It keeps `darlingserver`, launchd/mldr,
sockets and `.init.pid`, stops only the stale client process tree, and then lets
the normal launcher start the product again. It recognizes `LeagueClientUx`,
`Riot Client` and `RiotClientServices` windows, does nothing while a game process
is running or when a managed client window is visible, and fails closed when
X11/Xwayland properties are missing or unreadable. This saves the Darling
bootstrap but not the Riot Client/CEF startup itself. If no window appears
afterward, use a full runtime shutdown/restart.
### Multiple client-in-game helpers
The close/hide helper is a long-lived watcher. If an earlier launcher exits
while it remains, the next launch can start another copy; list them with:
```sh
pgrep -af 'close-league-client-in-game.py|hide-league-client-in-game.py'
```
To remove the orphaned copies before the next helper starts:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh
```
Only helpers of the current prefix whose parent is `init`/`systemd` are
terminated. Helpers whose launcher is still alive, other prefixes, the game,
Riot/League/CEF, `darlingserver` and the runtime are never touched. A dry run
is available from the configured tools directory:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 python3 build/runtime/tools/clean-stale-helpers.py --dry-run
```
### Client window stays black after a match
The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers)
@@ -192,6 +227,60 @@ observation, without waiting for a match:
python3 tools/close-league-client-in-game.py --once --dry-run
```
### Automatic client handling during a match
A single opt-in switch reads the client's own preferences and chooses the mode:
```sh
RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
```
It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`:
| Client preference | Launcher action |
|---|---|
| `potatoModeEnabled: false` or unreadable | no client-in-game helper |
| `potatoModeEnabled: true`, close preference missing or invalid | no action |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows |
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 hide helper exits after
ten minutes without a client or match once it has seen one; a missing or invalid
close preference selects no helper.
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
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.
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:
```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
+42 -7
View File
@@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644
};
};
diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp
index c3b1b93..3dab2ce 100644
index c3b1b93..6b8783d 100644
--- a/private-include/indium/command-encoder.private.hpp
+++ b/private-include/indium/command-encoder.private.hpp
@@ -1,6 +1,7 @@
@@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644
#include <memory>
#include <optional>
#include <array>
@@ -17,117 +18,295 @@
@@ -17,117 +18,330 @@
#include <iridium/iridium.hpp>
@@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644
+ struct AddressPool {
+ std::shared_ptr<Buffer> chunk;
+ size_t used = 0;
+
+ // Two shader stages are live in the render encoder. Cache their small
+ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk.
+ // This is only a read cache: misses retain the original byte comparison.
+ struct Shadow {
+ bool valid = false;
+ uint32_t offset;
+ VkDeviceSize range;
+ size_t count;
+ std::array<uint64_t, 64> addresses;
+ };
+ std::array<Shadow, 2> shadows;
+ size_t nextShadow = 0;
+
+ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const {
+ for (const auto& shadow : shadows) {
+ // A table's last eight bytes can be padding. If a new layout
+ // reads that padding, only the mapped bytes define the old key.
+ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count)
+ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ }
+ return !memcmp(static_cast<const char*>(chunk->contents()) + offset, addresses, count * sizeof(uint64_t));
+ }
+
+ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) {
+ if (count > 64) return;
+ auto& shadow = shadows[nextShadow];
+ nextShadow = (nextShadow + 1) % shadows.size();
+ shadow.offset = offset;
+ shadow.range = range;
+ shadow.count = count;
+ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ shadow.valid = true;
+ }
+ };
- if (functionResources.buffers.size() > 0) {
@@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644
+ if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize &&
+ previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset &&
+ previous->buffer == static_cast<PrivateBuffer*>(addressPool.chunk.get())->buffer() &&
+ !memcmp(static_cast<const char*>(addressPool.chunk->contents()) + previous->offset,
+ addresses.data(), addresses.size() * sizeof(uint64_t)))
+ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size()))
+ return *previous;
+ size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1);
+ if (!addressPool.chunk || tableOffset + tableSize > chunkSize) {
+ addressPool.chunk = privateDevice->acquireScratchChunk();
+ for (auto& shadow : addressPool.shadows) shadow.valid = false;
+ // we need to keep the chunk alive until the operation is completed
+ keepAliveBuffers.push_back(addressPool.chunk);
+ tableOffset = 0;
+ }
+ memcpy(static_cast<char*>(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t));
+ if (previous) addressPool.rememberAddresses(static_cast<uint32_t>(tableOffset), tableSize, addresses.data(), addresses.size());
+ addressPool.used = tableOffset + tableSize;
+ // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw
+ auto* privateAddressBuffer = static_cast<PrivateBuffer*>(addressPool.chunk.get());
@@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644
descSet.descriptorCount = 1;
descSet.pBufferInfo = &info;
}
@@ -136,17 +315,27 @@ namespace Indium {
@@ -136,17 +350,27 @@ namespace Indium {
auto& bindingInfo = funcInfo.bindings[j];
if (bindingInfo.type == Iridium::BindingType::Texture) {
@@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644
auto& descSet = writeDescSet.emplace_back();
descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descSet.dstSet = descriptorSets[i];
@@ -156,19 +345,21 @@ namespace Indium {
@@ -156,19 +380,21 @@ namespace Indium {
descSet.descriptorCount = 1;
descSet.pImageInfo = &info;
} else if (bindingInfo.type == Iridium::BindingType::Sampler) {
@@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644
info.sampler = privateSampler->sampler();
auto& descSet = writeDescSet.emplace_back();
@@ -188,10 +379,4 @@ namespace Indium {
@@ -188,10 +414,4 @@ namespace Indium {
return descriptorSets;
};
+175
View File
@@ -0,0 +1,175 @@
// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted
// from the authoritative component patch, not copied into this fixture.
#include <algorithm>
#include <array>
#include <cassert>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <new>
#include <vector>
static size_t allocationCalls = 0;
void* operator new(size_t size) {
++allocationCalls;
if (auto* p = std::malloc(size ? size : 1)) return p;
throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, size_t) noexcept { std::free(p); }
using VkBuffer = uint64_t;
using VkDeviceSize = uint64_t;
constexpr VkBuffer VK_NULL_HANDLE = 0;
namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; }
static bool enabled = true;
bool bindingReuseEnabled() { return enabled; }
void logUnboundResource(const char*, size_t, size_t, size_t) {}
struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; };
struct PrivateBuffer : Buffer {
uint64_t id, address;
size_t reads = 0;
std::vector<unsigned char> bytes;
PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {}
void* contents() override { ++reads; return bytes.data(); }
VkBuffer buffer() const { return id; }
uint64_t gpuAddress() const { return address; }
};
struct PrivateDevice {
size_t chunks = 0;
uint64_t fallback = 0xf00d;
uint64_t fallbackBufferAddress() { return fallback; }
std::shared_ptr<Buffer> acquireScratchChunk() { ++chunks; return std::make_shared<PrivateBuffer>(chunks, chunks << 20); }
};
struct FunctionResources { std::vector<std::pair<std::shared_ptr<Buffer>, size_t>> buffers; };
struct Resolved { PrivateBuffer* value = nullptr; };
struct ResolvedFunctionResources { std::vector<Resolved> buffers; };
struct Binding { Iridium::BindingType type; size_t index; };
struct FunctionInfo { std::vector<Binding> bindings; };
/* ACTUAL_PATCHED_HELPER */
struct Check {
AddressPool pool;
std::shared_ptr<PrivateDevice> device = std::make_shared<PrivateDevice>();
std::vector<std::shared_ptr<Buffer>> keep;
std::array<BufferAddressTable, 4> tables{};
Check() { keep.reserve(8); }
PrivateBuffer* mapped() { return static_cast<PrivateBuffer*>(pool.chunk.get()); }
void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info,
bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) {
auto old = tables[stage];
auto used = pool.used, chunks = device->chunks, calls = allocationCalls;
auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool,
previous ? &tables[stage] : nullptr);
// Only the existing DescriptorScratch overflow and new scratch chunk
// may allocate (make_shared control/object plus the chunk byte vector).
assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) +
(device->chunks - chunks) * 2);
assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse);
assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(),
[](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15)));
assert(keep.size() == device->chunks);
size_t index = 0;
for (const auto& binding : info.bindings) {
if (binding.type != Iridium::BindingType::Buffer) continue;
PrivateBuffer* buffer = nullptr;
if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value;
else if (binding.index < resources.buffers.size()) buffer = dynamic_cast<PrivateBuffer*>(resources.buffers[binding.index].first.get());
uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback;
uint64_t actual;
memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8);
assert(actual == expected);
}
tables[stage] = table;
}
};
FunctionInfo info(size_t count, size_t start = 0) {
FunctionInfo result;
for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i});
return result;
}
int main() {
// Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI.
static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr<Buffer>) + sizeof(size_t) + 1080);
FunctionResources r;
auto resource = std::make_shared<PrivateBuffer>(100, 0x1000);
for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16});
Check p;
auto f = info(3);
p.run(0, r, f, false);
auto accesses = p.mapped()->reads;
p.run(0, r, f, true); assert(p.mapped()->reads == accesses);
r.buffers[100].second++; p.run(0, r, f, true);
auto equivalent = std::make_shared<PrivateBuffer>(101, 0x0ff0);
r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true);
r.buffers[1].second++; p.run(0, r, f, false);
auto mixed = f;
mixed.bindings.push_back({Iridium::BindingType::Texture, 999});
mixed.bindings.push_back({Iridium::BindingType::Sampler, 999});
p.run(0, r, mixed, true);
p.run(1, r, info(4, 20), false);
accesses = p.mapped()->reads;
p.run(0, r, f, true); p.run(1, r, info(4, 20), true);
assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys
for (size_t i = 0; i < 5; ++i) {
r.buffers[20].second++; p.run(1, r, info(4, 20), false);
}
accesses = p.mapped()->reads;
p.run(0, r, f, true);
assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback
Check padding;
padding.run(0, r, info(1), false);
r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())};
accesses = padding.mapped()->reads;
padding.run(0, r, info(2), true); // odd -> even reads real padding
assert(padding.mapped()->reads == accesses + 1);
r.buffers[1].second++; padding.run(0, r, info(2), false);
accesses = padding.mapped()->reads;
padding.run(0, r, info(1), true); // even -> odd needs only cached prefix
assert(padding.mapped()->reads == accesses);
padding.run(0, r, info(3), false);
Check large;
for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) {
large.run(0, r, info(count), false);
large.run(0, r, info(count), true);
}
r.buffers[64].second++; large.run(0, r, info(65), false);
large.run(1, r, info(8), false, nullptr, false);
large.run(1, r, info(8), true);
Check missing;
FunctionResources empty;
missing.run(0, empty, info(2, 999), false);
missing.run(0, empty, info(2, 999), true);
missing.device->fallback++; missing.run(0, empty, info(2, 999), false);
ResolvedFunctionResources resolved;
resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get();
missing.run(1, r, info(1), false, &resolved);
missing.run(1, r, info(1), true, &resolved);
resolved.buffers[0].value = nullptr;
missing.run(1, r, info(1), false, &resolved);
Check roll;
roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false);
auto oldChunk = roll.pool.chunk;
roll.pool.used = 256*1024;
r.buffers[0].second++; roll.run(0, r, info(2), false);
assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2);
// Old offset 256 must not supply a stale key for untouched new-chunk bytes.
roll.tables[1] = {roll.tables[0].buffer, 256, 16};
roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true);
enabled = false; roll.run(1, r, info(2, 3), false); enabled = true;
Check guard;
guard.run(0, r, info(0), false); guard.run(0, r, info(0), true);
auto id = guard.tables[0].buffer;
guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true);
guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false);
accesses = guard.mapped()->reads;
guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false);
assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read
guard.tables[0].buffer += 100; guard.run(0, r, info(1), false);
guard.tables[0].range = 32; guard.run(0, r, info(1), false);
}
+110
View File
@@ -0,0 +1,110 @@
"""Exercise orphan-helper cleanup with fake /proc entries and mocked signals."""
import importlib.util
import io
import os
from pathlib import Path
import signal
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'clean_stale_helpers', ROOT / 'tools/clean-stale-helpers.py')
clean_stale_helpers = importlib.util.module_from_spec(spec)
spec.loader.exec_module(clean_stale_helpers)
HELPER = '/repo/tools/close-league-client-in-game.py'
HIDE_HELPER = '/repo/tools/hide-league-client-in-game.py'
class CleanStaleHelpersTests(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.other = self.root / 'other-prefix'
def parent(self, pid, comm):
folder = self.proc / str(pid)
folder.mkdir(exist_ok=True)
(folder / 'comm').write_text(comm + '\n')
def process(self, pid, script=HELPER, parent_pid=500, parent_comm='systemd',
prefix='own', argv0='/usr/bin/python3', state='S',
environment=True, arguments=('--detach',)):
folder = self.proc / str(pid)
folder.mkdir()
argv = [argv0, script, *arguments]
(folder / 'cmdline').write_bytes(
b'\0'.join(arg.encode() for arg in argv) + b'\0')
if parent_comm is not None:
self.parent(parent_pid, parent_comm)
fields = [state, str(parent_pid)] + ['0'] * 17 + ['100']
(folder / 'stat').write_text(f'{pid} (python3) ' + ' '.join(fields) + '\n')
if environment:
prefix = self.prefix if prefix == 'own' else prefix
(folder / 'environ').write_bytes(
b'DPREFIX=' + os.fsencode(prefix) + b'\0')
else:
(folder / 'environ').write_bytes(b'UNRELATED=1\0')
return folder
def run_clean(self, dry_run=False):
killed = []
def kill(pid, signum):
killed.append((pid, signum))
out = io.StringIO()
result = clean_stale_helpers.clean(
self.prefix, proc_root=self.proc, kill=kill,
sleep=lambda _seconds: None, out=out, dry_run=dry_run)
return result, killed, out.getvalue()
def test_removes_only_orphaned_helpers_of_this_prefix(self):
self.process(100, HELPER)
self.process(101, HIDE_HELPER)
self.process(200, HELPER, parent_pid=600, parent_comm='darling')
self.process(201, HELPER, prefix=self.other)
self.process(202, HELPER, argv0='/usr/bin/vim')
self.process(203, HELPER, state='Z')
self.process(204, HELPER, environment=False)
self.process(205, HELPER, parent_pid=700, parent_comm=None)
result, killed, output = self.run_clean()
self.assertEqual(result, 0)
self.assertEqual(sorted(killed), sorted([
(100, signal.SIGTERM), (100, signal.SIGKILL),
(101, signal.SIGTERM), (101, signal.SIGKILL),
]))
self.assertIn('removing 2 orphaned helper(s): 100,101', output)
def test_dry_run_reports_without_signalling(self):
self.process(100)
result, killed, output = self.run_clean(dry_run=True)
self.assertEqual((result, killed), (0, []))
self.assertIn('would remove 1 orphaned helper(s): 100', output)
def test_no_orphans_is_a_noop(self):
self.process(200, parent_pid=600, parent_comm='darling')
result, killed, output = self.run_clean()
self.assertEqual((result, killed), (0, []))
self.assertIn('no orphaned helpers', output)
def test_relative_helper_prefix_is_preserved(self):
self.process(100, prefix='relative-prefix')
result, killed, output = self.run_clean()
self.assertEqual((result, killed), (0, []))
self.assertIn('no orphaned helpers', output)
def test_launcher_runs_cleanup_before_starting_a_helper(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertIn('RCLOL_CLEAN_STALE_HELPERS', source)
self.assertLess(source.index('clean-stale-helpers.py'),
source.index('close-league-client-in-game.py'))
if __name__ == '__main__':
unittest.main()
+99
View File
@@ -0,0 +1,99 @@
"""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_none_when_close_preference_is_missing_or_invalid(self):
self.write(True)
self.assertEqual(tricks.mode(self.path), 'none')
for value in ('maybe', '"true', 'true#note', '"true"junk'):
self.write(True, value)
self.assertEqual(tricks.mode(self.path), 'none', value)
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(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: false\n'
' closeLeagueClientDuringGame: false\n'
' potatoModeEnabled: "true"\n'
' closeLeagueClientDuringGame: "true"\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_keys_outside_the_named_section_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: false\n'
'other-section:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: true\n')
self.assertEqual(tricks.mode(self.path), 'hide')
def test_inline_yaml_comments_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true # client optimizations\n'
' closeLeagueClientDuringGame: "true" # close during matches\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_second_read_failure_fails_closed(self):
self.write(True, False)
text = self.path.read_text()
with patch.object(Path, 'read_text', side_effect=[text, OSError()]):
self.assertEqual(tricks.mode(self.path), 'none')
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()
+15 -3
View File
@@ -23,10 +23,12 @@ REQUIRED = {
'__NV_PRIME_RENDER_OFFLOAD': '1', '__EGL_VENDOR_LIBRARY_FILENAMES': '/usr/share/glvnd/egl_vendor.d/10_nvidia.json',
'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.json', 'LIBGL_ALWAYS_SOFTWARE': '0',
'LD_PRELOAD': 'libgamemodeauto.so.0', 'LD_LIBRARY_PATH': '/run/opengl-driver/lib',
'HOME': '/home/u', 'USER': 'u', 'PATH': '/usr/bin:/bin', 'LANG': 'en_US.UTF-8', 'TERM': 'xterm',
'HOME': '/home/u', 'USER': 'u', 'PATH': '/usr/bin:/bin', 'LANG': 'en_US.UTF-8',
'DPREFIX': '/home/u/.darling', 'RCLOL_REFRESH_HZ': '144', 'DYLD_LIBRARY_PATH': '/Users/u/RuntimeLibraries',
}
SESSION = {
'TERM': 'xterm', 'STARSHIP_SESSION_KEY': 'host-prompt', 'AWS_PROFILE': 'host-profile',
'GIT_CONFIG_GLOBAL': '/home/u/.gitconfig', 'EDITOR': '/bin/host-editor',
'WAYLAND_DISPLAY': 'wayland-0', 'XDG_CURRENT_DESKTOP': 'GNOME', 'XDG_SESSION_TYPE': 'wayland',
'XDG_SESSION_ID': '2', 'XDG_SEAT': 'seat0', 'DESKTOP_SESSION': 'gnome', 'GDMSESSION': 'gnome',
'GDM_LANG': 'en_US.UTF-8', 'SESSION_MANAGER': 'local/host:@/tmp/.ICE-unix/1', 'GNOME_SETUP_DISPLAY': ':1',
@@ -45,7 +47,13 @@ class FilterTests(unittest.TestCase):
environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'}
removed = exec_guest.filter_host_environment(environ)
self.assertEqual(set(removed), set(SESSION))
self.assertEqual(environ, {**REQUIRED, 'SHELL': '/bin/zsh'})
self.assertEqual(environ, {**REQUIRED, 'SHELL': '/bin/zsh', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'})
def test_explicit_keep_preserves_host_path_and_terminal(self):
environ = {'PATH': '/custom/bin', 'TERM': 'xterm',
'RCLOL_KEEP_HOST_ENV': 'PATH TERM', 'RCLOL_FILTER_HOST_ENV': '1'}
self.assertEqual(exec_guest.filter_host_environment(environ), [])
self.assertEqual(environ, {'PATH': '/custom/bin', 'TERM': 'xterm', 'SHELL': '/bin/zsh'})
def test_keep_list(self):
environ = {'WAYLAND_DISPLAY': 'wayland-0', 'SSH_AUTH_SOCK': '/s', 'SHELL': '/bin/fish',
@@ -53,6 +61,7 @@ class FilterTests(unittest.TestCase):
self.assertEqual(exec_guest.filter_host_environment(environ), ['SSH_AUTH_SOCK'])
self.assertEqual(environ['WAYLAND_DISPLAY'], 'wayland-0')
self.assertEqual(environ['SHELL'], '/bin/fish')
self.assertNotIn('RCLOL_KEEP_HOST_ENV', environ)
class ExecTests(unittest.TestCase):
@@ -82,8 +91,11 @@ class ExecTests(unittest.TestCase):
self.assertEqual(child['fds'], [0, 1, 2])
self.assertFalse(set(SESSION) & set(child['env']))
for name, value in REQUIRED.items():
self.assertEqual(child['env'].get(name), value, name)
expected = '/usr/bin:/bin:/usr/sbin:/sbin' if name == 'PATH' else value
self.assertEqual(child['env'].get(name), expected, name)
self.assertEqual(child['env']['SHELL'], '/bin/zsh')
self.assertNotIn('RCLOL_FILTER_HOST_ENV', child['env'])
self.assertNotIn('RCLOL_KEEP_HOST_ENV', child['env'])
if __name__ == '__main__':
+346
View File
@@ -0,0 +1,346 @@
"""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 Mock, 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_unreadable_process_directory_is_unknown(self):
class UnreadableProc:
def iterdir(self):
raise OSError()
self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc()))
def test_host_socket_for_guest_uses_nspid(self):
host = self.proc / '999'
host.mkdir()
(host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n')
(host / 'environ').write_bytes(self.environment(self.prefix))
self.assertEqual(
hide_in_game.host_socket_for_guest(5877, self.proc),
os.fsencode(self.prefix / hide_in_game.SOCK_NAME),
)
def test_window_belongs_to_prefix(self):
socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME)
with patch.object(hide_in_game, 'window_pid', return_value=5877), \
patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket):
self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix))
self.assertFalse(hide_in_game.window_belongs_to_prefix(
'0x10', self.root / 'other'))
def test_once_runs_without_opt_in(self):
output = io.StringIO()
with patch.dict(os.environ, {
'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, 'game_running', return_value=True), \
patch.object(hide_in_game.sys, 'stdout', output):
self.assertEqual(hide_in_game.main(['--once']), 0)
self.assertIn('match=running', output.getvalue())
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"',
]), \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30])
def test_target_windows_requires_this_prefix(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"'), \
patch.object(hide_in_game, 'window_belongs_to_prefix',
side_effect=[True, False]):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10])
def test_target_windows_caches_non_empty_classes(self):
cache = {}
with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"') as classes, \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(classes.call_count, 1)
def test_missing_wm_class_is_not_cached_as_a_class(self):
with patch.object(hide_in_game, 'xprop_value',
return_value='WM_CLASS: not found.'):
self.assertEqual(hide_in_game.window_class('0x10'), '')
def test_unmap_failure_removes_the_window_from_hidden(self):
class FailingX11(FakeX11):
def unmap(self, window):
raise RuntimeError('unmap failed')
watcher = hide_in_game.Watcher(
self.prefix, FailingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, set())
def test_interrupted_unmap_keeps_the_window_tracked(self):
shown = []
class InterruptingX11:
def unmap(self, window):
raise KeyboardInterrupt
def show(self, window):
shown.append(window)
watcher = hide_in_game.Watcher(
self.prefix, InterruptingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
with self.assertRaises(KeyboardInterrupt):
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
watcher.restore()
self.assertEqual(shown, [0xabc])
def test_log_failure_does_not_discard_an_unmapped_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
watcher = hide_in_game.Watcher(
self.prefix, FakeX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
def test_restore_log_failure_still_shows_the_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher.hidden = {0xabc}
watcher._show_all('test')
self.assertEqual(x11.shown, [0xabc])
self.assertEqual(watcher.hidden, set())
def test_idle_timeout_leaves_after_client_disappears(self):
states = iter([True, False, False])
windows = iter([[0xabc], [], []])
times = iter([0.0, 10.0, 700.0])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: next(windows),
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
clock=lambda: next(times),
)
self.assertEqual(watcher.run(max_steps=3), 0)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('no client or match', output.getvalue())
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 _prefix, _cache: [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 _prefix, _cache: [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 _prefix, _cache: [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)
def test_helper_starts_after_the_installed_client_check(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertLess(source.index('Riot Client is not installed in'),
source.index('RCLOL_CLIENT_IN_GAME_TRICKS'))
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()
+50
View File
@@ -0,0 +1,50 @@
"""Compile the shipped address-table helper against an inert CPU-only fixture."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HEADER = 'private-include/indium/command-encoder.private.hpp'
def address_helper():
patch = (ROOT / 'patches/metal--deps--indium.patch').read_text()
marker = f'diff --git a/{HEADER} b/{HEADER}\n'
section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0]
# Extract each postimage hunk separately: never silently join a source gap.
hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:]
postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True)
if line.startswith((' ', '+'))) for hunk in hunks]
def region(start, end):
matches = [text[text.index(start):text.index(end, text.index(start))]
for text in postimages if start in text and end in text]
if len(matches) != 1:
raise AssertionError('expected one complete patched region: ' + start)
return matches[0]
return (region(' template<class T, size_t capacity = 64> class DescriptorScratch',
' // Per-encoder binding tables') +
region('\tstruct AddressPool {', '\n\ttemplate<size_t setCount>'))
@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required')
class AddressShadowChecks(unittest.TestCase):
def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self):
fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text()
with tempfile.TemporaryDirectory() as folder:
path = Path(folder)
source = path / 'check.cpp'
source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper()))
build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')],
capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stderr)
result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
"""Test the opt-in partial client-tree recovery with fake /proc entries."""
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(
'recover_stale_client', ROOT / 'tools/recover-stale-client.py')
recover_stale_client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(recover_stale_client)
GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends'
CLIENT = '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices'
class RecoverStaleClientTests(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.proc / 'uptime').write_text('1000 0\n')
self.prefix = self.root / '.darling'
def process(self, pid, comm, argv, env=None, executable=None, age=120):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'comm').write_text(comm + '\n')
(folder / 'cmdline').write_bytes(
(argv[0] if executable is None else executable).encode() + b'\0' +
b''.join(arg.encode() + b'\0' for arg in argv[1:]))
ticks = os.sysconf('SC_CLK_TCK')
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * ticks))]
(folder / 'stat').write_text(f'{pid} ({comm}) ' + ' '.join(fields) + '\n')
if env is not None:
(folder / 'environ').write_bytes(env)
return folder
def client_env(self, prefix=None):
prefix = self.prefix if prefix is None else prefix
return b'%s=%s\0' % (
recover_stale_client.SOCK_VARIABLE,
os.fsencode(prefix / recover_stale_client.SOCK_NAME),
)
def run_recover(self, window=False, on_kill=None):
killed = []
def kill(pid, signum):
killed.append((pid, signum))
if on_kill is not None:
on_kill(pid, signum)
out = io.StringIO()
with patch.object(recover_stale_client, 'visible_client_window',
return_value=window):
result = recover_stale_client.recover(
self.prefix, proc_root=self.proc, window_check=lambda: window,
kill=kill, sleep=lambda _seconds: None, out=out)
return result, killed, out.getvalue()
def test_no_darlingserver_does_nothing(self):
self.process(10, 'mldr', ['/sbin/launchd'])
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('no darlingserver', output)
def test_game_is_never_touched(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [GAME])
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('game running', output)
def test_visible_window_does_nothing(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [CLIENT], self.client_env())
result, killed, output = self.run_recover(window=True)
self.assertEqual((result, killed), (0, []))
self.assertIn('client window present', output)
def test_stale_client_is_killed_but_runtime_is_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', ['/sbin/launchd'])
self.process(12, 'mldr', [CLIENT], self.client_env())
result, killed, output = self.run_recover()
self.assertEqual(result, 0)
self.assertEqual([pid for pid, _sig in killed], [12, 12])
self.assertIn('stale client tree', output)
def test_other_prefix_and_game_are_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [CLIENT], self.client_env(self.root / 'other'))
self.process(12, 'mldr', [GAME], self.client_env())
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('game running', output)
def test_runtime_process_names_are_not_killed(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', ['/sbin/launchd'])
self.process(12, 'mldr', [CLIENT], self.client_env())
result, killed, _output = self.run_recover()
self.assertEqual(result, 0)
self.assertNotIn(10, [pid for pid, _sig in killed])
self.assertNotIn(11, [pid for pid, _sig in killed])
def test_pid_identity_is_rechecked_before_sigkill(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(12, 'mldr', [CLIENT], self.client_env())
def reuse_pid(pid, signum):
if signum == recover_stale_client.signal.SIGTERM:
(self.proc / str(pid) / 'cmdline').write_bytes(b'/usr/bin/editor\0')
result, killed, _output = self.run_recover(on_kill=reuse_pid)
self.assertEqual(result, 0)
self.assertEqual(killed, [(12, recover_stale_client.signal.SIGTERM)])
def test_young_client_is_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(12, 'mldr', [CLIENT], self.client_env(),
age=recover_stale_client.MIN_AGE_SECONDS - 1)
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('no stale client processes', output)
def test_missing_net_client_list_is_inconclusive(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
return_value='_NET_CLIENT_LIST: not found.\n'):
self.assertTrue(recover_stale_client.visible_client_window())
def test_empty_client_list_is_no_window(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
return_value='_NET_CLIENT_LIST(WINDOW): window id #\n'):
self.assertFalse(recover_stale_client.visible_client_window())
def test_missing_wm_class_is_inconclusive(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
side_effect=[
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
'WM_CLASS: not found.\n',
]):
self.assertTrue(recover_stale_client.visible_client_window())
def test_riot_client_shell_is_a_visible_window(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
side_effect=[
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
'WM_CLASS(STRING) = "RiotClientServices", "RiotClientServices"\n',
]):
self.assertTrue(recover_stale_client.visible_client_window())
if __name__ == '__main__':
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Remove orphaned client-in-game helpers from an earlier launcher run.
The close/hide helpers are long-lived watch processes. A launcher can end
while they remain, and a later run starts another helper, so several copies
watch the same prefix. This opt-in preflight removes only helpers that belong
to this prefix and whose parent is init/systemd: the launcher that started
them has already exited and reparented them. It never signals a helper whose
parent is still alive, the current launcher, darlingserver, the game,
Riot/League/CEF, or a process of another prefix.
It fails closed: an unreadable command line, environment, stat entry or
parent leaves that process alone. Enable it with
RCLOL_CLEAN_STALE_HELPERS=1; the launcher runs it before starting its own
helper.
"""
import argparse
import os
import signal
import sys
import time
from pathlib import Path
HELPER_NAMES = ('close-league-client-in-game.py',
'hide-league-client-in-game.py')
REAPER_NAMES = ('init', 'systemd')
TERM_GRACE = 3.0
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_info(proc):
"""Return (state, ppid, start_ticks), or None when stat is unreadable."""
try:
fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split()
if len(fields) < 20:
return None
return fields[0], int(fields[1]), int(fields[19])
except (OSError, ValueError, IndexError):
return None
def parent_is_reaper(ppid, proc_root):
"""True when the parent is PID 1 or a systemd/init user manager."""
if ppid == 1:
return True
try:
comm = (proc_root / str(ppid) / 'comm').read_text().strip()
except OSError:
return False
return comm in REAPER_NAMES
def helper_identity(proc, prefix, proc_root):
"""Return the start ticks of an orphaned helper of prefix, or None.
The script name must be an exact argument, and the executable must be a
Python interpreter. This avoids matching an editor or a log viewer that
happens to have the helper name in its arguments. DPREFIX is inherited by
the helper from run-riot-client.sh; a missing or different value belongs
to another install and is preserved.
"""
try:
if proc.stat().st_uid != os.getuid():
return None
raw = (proc / 'cmdline').read_bytes()
argv = [part.decode(errors='replace') for part in raw.split(b'\0') if part]
if len(argv) < 2 or not os.path.basename(argv[0]).startswith(('python', 'pypy')):
return None
if not any(os.path.basename(arg) in HELPER_NAMES for arg in argv[1:]):
return None
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
raw_prefix = environment.get(b'DPREFIX')
if raw_prefix is None:
return None
helper_prefix = Path(os.fsdecode(raw_prefix)).expanduser()
if not helper_prefix.is_absolute():
return None
helper_prefix = helper_prefix.resolve()
if helper_prefix != prefix:
return None
info = process_info(proc)
if info is None or info[0] == 'Z':
return None
if not parent_is_reaper(info[1], proc_root):
return None
return info[2]
except FileNotFoundError:
return None
except (OSError, RuntimeError, ValueError):
return None
def clean(prefix, proc_root=Path('/proc'), kill=os.kill, sleep=time.sleep,
out=sys.stdout, dry_run=False):
"""Terminate orphaned helpers for prefix; return 0 on every normal path."""
if prefix is None or not prefix.is_absolute():
print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True)
return 1
try:
prefix = prefix.expanduser().resolve()
except (OSError, RuntimeError):
print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True)
return 1
try:
entries = list(proc_root.iterdir())
except OSError:
print('clean-stale-helpers: unreadable /proc: no action', file=out, flush=True)
return 0
helpers = {}
for proc in entries:
if not proc.name.isdigit() or int(proc.name) == os.getpid():
continue
start_ticks = helper_identity(proc, prefix, proc_root)
if start_ticks is not None:
helpers[int(proc.name)] = start_ticks
if not helpers:
print('clean-stale-helpers: no orphaned helpers; nothing to do',
file=out, flush=True)
return 0
pids = sorted(helpers)
action = 'would remove' if dry_run else 'removing'
print('clean-stale-helpers: %s %d orphaned helper(s): %s'
% (action, len(pids), ','.join(str(pid) for pid in pids)),
file=out, flush=True)
if dry_run:
return 0
for pid, start_ticks in helpers.items():
proc = proc_root / str(pid)
if helper_identity(proc, prefix, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGTERM)
except OSError:
continue
sleep(TERM_GRACE)
for pid, start_ticks in helpers.items():
proc = proc_root / str(pid)
if helper_identity(proc, prefix, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGKILL)
except OSError:
pass
return 0
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--prefix', type=Path,
help='prefix whose helpers should be checked')
parser.add_argument('--dry-run', action='store_true',
help='report what would be removed without signalling')
args = parser.parse_args()
if os.environ.get('RCLOL_CLEAN_STALE_HELPERS') != '1':
print('clean-stale-helpers: disabled; set RCLOL_CLEAN_STALE_HELPERS=1',
flush=True)
return 0
prefix = args.prefix or target_prefix()
if prefix is None or not prefix.is_absolute():
print('clean-stale-helpers: unusable DPREFIX: no action',
file=sys.stderr, flush=True)
return 1
return clean(prefix, dry_run=args.dry_run)
if __name__ == '__main__':
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/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 or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action
(none).
Only the named `lol-user-experience` section is considered, so identical keys
in another section cannot select a helper. This helper only selects a mode; it
does not change preferences or files.
"""
import sys
from pathlib import Path
SECTION = 'lol-user-experience'
CLOSE_KEY = 'closeLeagueClientDuringGame'
OPTIMIZATION_KEY = 'potatoModeEnabled'
TRUE_VALUES = {'true', 'yes', '1'}
FALSE_VALUES = {'false', 'no', '0'}
def parse_bool(value):
"""Return True/False/None for one YAML scalar, ignoring a trailing comment."""
value = value.strip()
if not value:
return None
if value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return None
scalar = value[1:end]
remainder = value[end + 1:]
if remainder and not remainder[0].isspace():
return None
remainder = remainder.strip()
if remainder and not remainder.startswith('#'):
return None
else:
scalar = value
for index, char in enumerate(scalar):
if char == '#' and (index == 0 or scalar[index - 1].isspace()):
scalar = scalar[:index].rstrip()
break
scalar = scalar.lower()
if scalar in TRUE_VALUES:
return True
if scalar in FALSE_VALUES:
return False
return None
def read_bool(path, key, section=SECTION):
"""Return True/False for the last key in section, or None when invalid.
OSError/UnicodeError is raised for an unreadable file so callers can fail
closed instead of treating a read race as a missing preference.
"""
lines = Path(path).read_text().splitlines()
in_section = False
value = None
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if not line.startswith((' ', '\t')):
if stripped == section + ':':
in_section = True
elif in_section:
break
continue
if not in_section or not stripped.startswith(key + ':'):
continue
value = parse_bool(stripped.split(':', 1)[1])
return value
def mode(path):
try:
optimization = read_bool(path, OPTIMIZATION_KEY)
except (OSError, UnicodeError):
return 'none'
if optimization is not True:
return 'none'
try:
close = read_bool(path, CLOSE_KEY)
except (OSError, UnicodeError):
return 'none'
if close is True:
return 'close'
if close is False:
return 'hide'
return 'none'
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())
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Opt-in: hide the League client UI while a match is 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
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
IDLE_SECONDS = 600
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 xprop_value(window, prop):
try:
return subprocess.check_output(
['xprop', '-id', window, prop], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return ''
def window_class(window):
value = xprop_value(window, 'WM_CLASS').lower()
return value if '=' in value else ''
def window_pid(window):
match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID'))
return int(match.group(1)) if match else None
def _socket_from_process(proc):
try:
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
except (OSError, ValueError):
return None
return environment.get(SOCK_VARIABLE)
def host_socket_for_guest(guest_pid, proc_root=Path('/proc')):
"""Find the host process for a guest X11 PID and return its loader socket.
Darling's X11 window stores the guest PID in _NET_WM_PID, while the
process visible in /proc is the host loader. NSpid carries both IDs. The
direct /proc/<pid> case is checked too for a native process.
"""
direct = proc_root / str(guest_pid)
if direct.exists():
socket = _socket_from_process(direct)
if socket is not None:
return socket
sockets = set()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit():
continue
try:
status = (proc / 'status').read_text()
except OSError:
continue
for line in status.splitlines():
if not line.startswith('NSpid:'):
continue
if str(guest_pid) not in line.split()[1:]:
continue
socket = _socket_from_process(proc)
if socket is not None:
sockets.add(socket)
return sockets.pop() if len(sockets) == 1 else None
def window_belongs_to_prefix(window, prefix):
pid = window_pid(window)
if pid is None:
return False
socket = host_socket_for_guest(pid)
if socket is None:
return False
try:
return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve()
except OSError:
return False
def target_windows(prefix, class_cache=None):
"""Managed client windows for this prefix; the game window is never selected.
WM_CLASS is cached only when non-empty. The cache is pruned when a window
leaves _NET_CLIENT_LIST; empty results are retried because a class can be
set after the window appears.
"""
current = []
for raw in xprop_root():
try:
current.append((int(raw, 16), raw))
except ValueError:
continue
current_ids = {window for window, _raw in current}
if class_cache is not None:
for stale in set(class_cache) - current_ids:
del class_cache[stale]
found = []
for window, raw in current:
classes = class_cache.get(window) if class_cache is not None else None
if not classes:
classes = window_class(raw)
if classes and class_cache is not None:
class_cache[window] = classes
if not classes or not any(name in classes for name in TARGET_CLASSES):
continue
if window_belongs_to_prefix(raw, prefix):
found.append(window)
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,
clock=time.monotonic):
self.prefix = prefix
self.x11 = x11
self.window_source = window_source
self.state_source = state_source
self.sleep = sleep
self.out = out
self.clock = clock
self.hidden = set()
self.class_cache = {}
self.reported_no_window = False
self.seen = False
self.idle_since = None
def _log(self, message, stream=None):
try:
print(message, file=self.out if stream is None else stream, flush=True)
except Exception:
# A closed launcher pipe must not prevent window restoration.
pass
def _show_all(self, reason):
if not self.hidden:
return
self._log(f'hide-league-client-in-game: {reason}; showing the client')
for window in sorted(self.hidden):
try:
self.x11.show(window)
except Exception as error:
self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: shown 0x{window:x}')
self.hidden.clear()
def _hide_new(self):
windows = self.window_source(self.prefix, self.class_cache)
if not windows and not self.hidden and not self.reported_no_window:
self._log('hide-league-client-in-game: no managed client window; not hiding')
self.reported_no_window = True
for window in windows:
if window in self.hidden:
continue
self.hidden.add(window)
try:
self.x11.unmap(window)
except Exception as error:
self.hidden.discard(window)
self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: hidden 0x{window:x}')
def _windows_present(self):
try:
return bool(self.window_source(self.prefix, self.class_cache))
except Exception:
# Uncertainty must not turn into an automatic exit.
return True
def run(self, max_steps=None):
steps = 0
while True:
now = self.clock()
state = self.state_source(self.prefix)
if state is None:
self._show_all('unreadable /proc state')
elif state:
self.seen = True
self.idle_since = None
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
if self._windows_present():
self.seen = True
self.idle_since = None
elif self.seen:
if self.idle_since is None:
self.idle_since = now
if now - self.idle_since >= IDLE_SECONDS:
self._log('hide-league-client-in-game: no client or match for '
f'{IDLE_SECONDS}s: leaving')
return 0
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)
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 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:
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
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(signum, stop)
optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks
else 'RCLOL_HIDE_CLIENT_IN_GAME=0')
print('hide-league-client-in-game: watching %s for a running match; '
'opt out with %s' % (prefix, optout), flush=True)
try:
watcher.run()
except KeyboardInterrupt:
pass
finally:
watcher.restore()
x11.close()
return 0
if __name__ == '__main__':
sys.exit(main())
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Partially recover a live Darling runtime with a stale client tree.
This is opt-in through RCLOL_RECOVER_STALE_CLIENT=1. It only acts when all
of these are true:
- a darlingserver is running;
- no League game process is running;
- no managed Riot/League client window is visible;
- one or more Riot/League client, CEF or crash-handler processes at least 60
seconds old are still present for this prefix.
It terminates only those client processes with SIGTERM/SIGKILL. The
darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the
next launcher run can reuse the runtime. It never patches files or the game.
"""
import math
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
GAME_NAME = 'LeagueofLegends'
WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices')
MIN_AGE_SECONDS = 60
CLIENT_MARKERS = (
'/Applications/League of Legends.app',
'/Applications/Riot Client.app',
'/Users/Shared/Riot Games/Riot Client.app',
'RiotClientServices',
'Riot Client',
'LeagueClient',
'LeagueClientUx',
'LeagueCrashHandler',
)
RUNTIME_NAMES = ('darlingserver', 'darling')
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
def target_prefix():
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else 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 command_text(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return ''
return b' '.join(part for part in raw.split(b'\0') if part).decode(
errors='replace')
def process_state(proc):
try:
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
except (OSError, IndexError):
return None
def server_running(proc_root=Path('/proc')):
try:
entries = list(proc_root.iterdir())
except OSError:
return False
for proc in entries:
if not proc.name.isdigit():
continue
try:
if (proc / 'comm').read_text().strip() == 'darlingserver':
return True
except OSError:
pass
return False
def game_running(proc_root=Path('/proc')):
for proc in proc_root.iterdir() if proc_root.exists() else ():
if not proc.name.isdigit():
continue
names = command_names(proc)
if GAME_NAME in names:
return True
return False
def visible_client_window():
"""Return True when a managed client window is present.
A missing xprop/property or display is treated as 'cannot prove there is no
window' so we do not kill a possibly working client. xprop exits zero and
prints "not found" for a property the window manager did not set.
"""
try:
listing = subprocess.check_output(
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return True
lowered = listing.lower()
if '_net_client_list' not in lowered or 'not found' in lowered:
return True
for raw in re.findall(r'0x[0-9a-fA-F]+', listing):
try:
classes = subprocess.check_output(
['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5
).lower()
except (OSError, subprocess.SubprocessError):
return True
if 'not found' in classes or '=' not in classes:
return True
if any(name in classes for name in WINDOW_CLASSES):
return True
return False
def client_identity(prefix, pid, proc_root=Path('/proc')):
"""Start ticks of a live stale client for prefix, or None when not eligible."""
proc = proc_root / str(pid)
if process_state(proc) in ('Z', None):
return None
owner = (prefix / SOCK_NAME).parent.resolve()
try:
if proc.stat().st_uid != os.getuid():
return None
names = command_names(proc)
if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names):
return None
text = command_text(proc)
if not any(marker in text for marker in CLIENT_MARKERS):
return None
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:
return None
if Path(os.fsdecode(socket)).parent.resolve() != owner:
return None
uptime = float((proc_root / 'uptime').read_text().split()[0])
ticks = os.sysconf('SC_CLK_TCK')
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
return None
# Field 22 follows a comm field that may contain spaces/parentheses.
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS:
return None
return start_ticks
except (OSError, ValueError, IndexError):
return None
def client_pids(prefix, proc_root=Path('/proc')):
"""Client/CEF PIDs belonging to prefix, excluding Darling and the game."""
found = []
try:
entries = list(proc_root.iterdir())
except OSError:
return []
for proc in entries:
if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None:
found.append(int(proc.name))
return sorted(found)
def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window,
kill=os.kill, sleep=time.sleep, out=sys.stdout):
if not server_running(proc_root):
print('recover-stale-client: no darlingserver; normal startup', file=out, flush=True)
return 0
if game_running(proc_root):
print('recover-stale-client: game running; no action', file=out, flush=True)
return 0
if window_check():
print('recover-stale-client: client window present; no action', file=out, flush=True)
return 0
identities = {}
for pid in client_pids(prefix, proc_root):
start_ticks = client_identity(prefix, pid, proc_root)
if start_ticks is not None:
identities[pid] = start_ticks
if not identities:
print('recover-stale-client: no stale client processes; no action', file=out, flush=True)
return 0
if window_check():
print('recover-stale-client: client window appeared; no action', file=out, flush=True)
return 0
print('recover-stale-client: stale client tree without a window; '
'terminating %d process(es)' % len(identities), file=out, flush=True)
for pid, start_ticks in identities.items():
if client_identity(prefix, pid, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGTERM)
except OSError:
pass
sleep(4)
for pid, start_ticks in identities.items():
if client_identity(prefix, pid, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGKILL)
except OSError:
pass
return 0
def main():
if os.environ.get('RCLOL_RECOVER_STALE_CLIENT') != '1':
print('recover-stale-client: disabled; set RCLOL_RECOVER_STALE_CLIENT=1',
flush=True)
return 0
prefix = target_prefix()
if prefix is None:
print('recover-stale-client: unusable DPREFIX; no action',
file=sys.stderr, flush=True)
return 1
return recover(prefix)
if __name__ == '__main__':
sys.exit(main())
+39 -6
View File
@@ -9,15 +9,21 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then
fi
runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
. "$runner_dir/runtime-paths.sh"
# Opt-in partial recovery: if Darling is alive but a previous client tree is
# windowless, stop only that client/CEF tree and reuse the runtime. Never
# touches the game, darlingserver or the runtime sockets.
if [ "${RCLOL_RECOVER_STALE_CLIENT:-0}" = 1 ]; then
python3 "$runner_dir/recover-stale-client.py"
fi
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
python3 "$runner_dir/close-league-client-in-game.py" --detach &
# Opt-in cleanup of helpers left by an earlier launcher. This runs before the
# current helper is started. It removes only helpers of this prefix whose
# parent is init/systemd, so a helper of a live launcher is not touched.
if [ "${RCLOL_CLEAN_STALE_HELPERS:-0}" = 1 ]; then
python3 "$runner_dir/clean-stale-helpers.py" --prefix "$prefix_dir" ||
echo 'clean-stale-helpers: cleanup failed; continuing' >&2
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.
@@ -74,6 +80,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then
ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client"
fi
fi
# Opt-in automatic client-in-game handling. Start the watcher only after the
# installed-client check above: a failed installer launch must not leave a
# helper behind. The preference selector returns no action for missing or
# invalid close-during-game values.
if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then
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 missing/invalid)' >&2
;;
esac
elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/close-league-client-in-game.py" --detach &
elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/hide-league-client-in-game.py" --detach &
fi
fi
A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"}
# External XIM (for example Fcitx) consumes keys before Darling forwards them.
# Use Xlib's local input method; allow explicit testing of another modifier.