mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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:
commit
65d4354667
22 files changed
+2465
-26
No files matched your search
@@ -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'
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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_*`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__':
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+183
@@ -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())
|
||||
Executable
+110
@@ -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())
|
||||
Executable
+425
@@ -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())
|
||||
Executable
+242
@@ -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())
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user