Merge main into fix/recover-stale-client

Resolve conflicts with #122: keep both feature docs and CI steps, and drop
this branch's copy of the close-helper block, which main now starts after
the installed-client check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
lucannez64andClaude Opus 5.5 committed 2026-10-23 14:18:33 +02:00
commit a11a144767
12 files changed
+1367 -14

No files matched your search

+7
View File
@@ -16,6 +16,10 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Require C++ regression compiler
run: |
command -v c++
c++ --version
- run: python scripts/validate.py
- run: python tools/riot-input-offline-check.py
- run: python tools/riot-launcher-offline-check.py
@@ -29,7 +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'
+18
View File
@@ -134,6 +134,24 @@ 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.
+14
View File
@@ -96,6 +96,20 @@ helper of a live launcher, another prefix, the game, Riot/League/CEF and
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_*`),
+54
View File
@@ -227,6 +227,60 @@ observation, without waiting for a match:
python3 tools/close-league-client-in-game.py --once --dry-run
```
### Automatic client handling during a match
A single opt-in switch reads the client's own preferences and chooses the mode:
```sh
RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
```
It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`:
| Client preference | Launcher action |
|---|---|
| `potatoModeEnabled: false` or unreadable | no client-in-game helper |
| `potatoModeEnabled: true`, close preference missing or invalid | no action |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows |
The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps
them again when the game process ends. The client is not closed, the League
backend and Riot client keep running, and the game window is never hidden. The
close path uses the UI-close helper described above. The hide helper exits after
ten minutes without a client or match once it has seen one; a missing or invalid
close preference selects no helper.
The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and
`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic
switch is not set. All modes are off by default. The helpers are
window-management only: they do not patch client binaries, CEF, anti-cheat or
DRM. They attribute the game to this prefix through the loader's
`__mldr_sockpath`, and fail closed when ownership, proc state, environment or
window information cannot be read. The hide helper requires an X11 or Xwayland
desktop; on a native Wayland window manager without Xwayland it cannot find the
windows.
On a CPU-bound host the hide effect appears after CEF has been hidden for a few
seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding
`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from
120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the
window returned it to 119.9%. This is not a universal FPS or percentage claim:
a game that is GPU-bound, a client with no active UI work, or a different
desktop may show less benefit. The hidden window can also need a moment to
repaint after it is shown.
With client optimizations enabled and close-during-game enabled, the close
helper remains the more aggressive option when in-client chat during the match
is not needed.
Diagnose the match detector without changing any window:
```sh
python3 tools/hide-league-client-in-game.py --once
```
### Riot reports NO INTERNET FOUND while the host is online
Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which
+42 -7
View File
@@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644
};
};
diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp
index c3b1b93..3dab2ce 100644
index c3b1b93..6b8783d 100644
--- a/private-include/indium/command-encoder.private.hpp
+++ b/private-include/indium/command-encoder.private.hpp
@@ -1,6 +1,7 @@
@@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644
#include <memory>
#include <optional>
#include <array>
@@ -17,117 +18,295 @@
@@ -17,117 +18,330 @@
#include <iridium/iridium.hpp>
@@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644
+ struct AddressPool {
+ std::shared_ptr<Buffer> chunk;
+ size_t used = 0;
+
+ // Two shader stages are live in the render encoder. Cache their small
+ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk.
+ // This is only a read cache: misses retain the original byte comparison.
+ struct Shadow {
+ bool valid = false;
+ uint32_t offset;
+ VkDeviceSize range;
+ size_t count;
+ std::array<uint64_t, 64> addresses;
+ };
+ std::array<Shadow, 2> shadows;
+ size_t nextShadow = 0;
+
+ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const {
+ for (const auto& shadow : shadows) {
+ // A table's last eight bytes can be padding. If a new layout
+ // reads that padding, only the mapped bytes define the old key.
+ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count)
+ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ }
+ return !memcmp(static_cast<const char*>(chunk->contents()) + offset, addresses, count * sizeof(uint64_t));
+ }
+
+ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) {
+ if (count > 64) return;
+ auto& shadow = shadows[nextShadow];
+ nextShadow = (nextShadow + 1) % shadows.size();
+ shadow.offset = offset;
+ shadow.range = range;
+ shadow.count = count;
+ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ shadow.valid = true;
+ }
+ };
- if (functionResources.buffers.size() > 0) {
@@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644
+ if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize &&
+ previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset &&
+ previous->buffer == static_cast<PrivateBuffer*>(addressPool.chunk.get())->buffer() &&
+ !memcmp(static_cast<const char*>(addressPool.chunk->contents()) + previous->offset,
+ addresses.data(), addresses.size() * sizeof(uint64_t)))
+ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size()))
+ return *previous;
+ size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1);
+ if (!addressPool.chunk || tableOffset + tableSize > chunkSize) {
+ addressPool.chunk = privateDevice->acquireScratchChunk();
+ for (auto& shadow : addressPool.shadows) shadow.valid = false;
+ // we need to keep the chunk alive until the operation is completed
+ keepAliveBuffers.push_back(addressPool.chunk);
+ tableOffset = 0;
+ }
+ memcpy(static_cast<char*>(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t));
+ if (previous) addressPool.rememberAddresses(static_cast<uint32_t>(tableOffset), tableSize, addresses.data(), addresses.size());
+ addressPool.used = tableOffset + tableSize;
+ // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw
+ auto* privateAddressBuffer = static_cast<PrivateBuffer*>(addressPool.chunk.get());
@@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644
descSet.descriptorCount = 1;
descSet.pBufferInfo = &info;
}
@@ -136,17 +315,27 @@ namespace Indium {
@@ -136,17 +350,27 @@ namespace Indium {
auto& bindingInfo = funcInfo.bindings[j];
if (bindingInfo.type == Iridium::BindingType::Texture) {
@@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644
auto& descSet = writeDescSet.emplace_back();
descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descSet.dstSet = descriptorSets[i];
@@ -156,19 +345,21 @@ namespace Indium {
@@ -156,19 +380,21 @@ namespace Indium {
descSet.descriptorCount = 1;
descSet.pImageInfo = &info;
} else if (bindingInfo.type == Iridium::BindingType::Sampler) {
@@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644
info.sampler = privateSampler->sampler();
auto& descSet = writeDescSet.emplace_back();
@@ -188,10 +379,4 @@ namespace Indium {
@@ -188,10 +414,4 @@ namespace Indium {
return descriptorSets;
};
+175
View File
@@ -0,0 +1,175 @@
// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted
// from the authoritative component patch, not copied into this fixture.
#include <algorithm>
#include <array>
#include <cassert>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <new>
#include <vector>
static size_t allocationCalls = 0;
void* operator new(size_t size) {
++allocationCalls;
if (auto* p = std::malloc(size ? size : 1)) return p;
throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, size_t) noexcept { std::free(p); }
using VkBuffer = uint64_t;
using VkDeviceSize = uint64_t;
constexpr VkBuffer VK_NULL_HANDLE = 0;
namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; }
static bool enabled = true;
bool bindingReuseEnabled() { return enabled; }
void logUnboundResource(const char*, size_t, size_t, size_t) {}
struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; };
struct PrivateBuffer : Buffer {
uint64_t id, address;
size_t reads = 0;
std::vector<unsigned char> bytes;
PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {}
void* contents() override { ++reads; return bytes.data(); }
VkBuffer buffer() const { return id; }
uint64_t gpuAddress() const { return address; }
};
struct PrivateDevice {
size_t chunks = 0;
uint64_t fallback = 0xf00d;
uint64_t fallbackBufferAddress() { return fallback; }
std::shared_ptr<Buffer> acquireScratchChunk() { ++chunks; return std::make_shared<PrivateBuffer>(chunks, chunks << 20); }
};
struct FunctionResources { std::vector<std::pair<std::shared_ptr<Buffer>, size_t>> buffers; };
struct Resolved { PrivateBuffer* value = nullptr; };
struct ResolvedFunctionResources { std::vector<Resolved> buffers; };
struct Binding { Iridium::BindingType type; size_t index; };
struct FunctionInfo { std::vector<Binding> bindings; };
/* ACTUAL_PATCHED_HELPER */
struct Check {
AddressPool pool;
std::shared_ptr<PrivateDevice> device = std::make_shared<PrivateDevice>();
std::vector<std::shared_ptr<Buffer>> keep;
std::array<BufferAddressTable, 4> tables{};
Check() { keep.reserve(8); }
PrivateBuffer* mapped() { return static_cast<PrivateBuffer*>(pool.chunk.get()); }
void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info,
bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) {
auto old = tables[stage];
auto used = pool.used, chunks = device->chunks, calls = allocationCalls;
auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool,
previous ? &tables[stage] : nullptr);
// Only the existing DescriptorScratch overflow and new scratch chunk
// may allocate (make_shared control/object plus the chunk byte vector).
assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) +
(device->chunks - chunks) * 2);
assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse);
assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(),
[](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15)));
assert(keep.size() == device->chunks);
size_t index = 0;
for (const auto& binding : info.bindings) {
if (binding.type != Iridium::BindingType::Buffer) continue;
PrivateBuffer* buffer = nullptr;
if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value;
else if (binding.index < resources.buffers.size()) buffer = dynamic_cast<PrivateBuffer*>(resources.buffers[binding.index].first.get());
uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback;
uint64_t actual;
memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8);
assert(actual == expected);
}
tables[stage] = table;
}
};
FunctionInfo info(size_t count, size_t start = 0) {
FunctionInfo result;
for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i});
return result;
}
int main() {
// Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI.
static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr<Buffer>) + sizeof(size_t) + 1080);
FunctionResources r;
auto resource = std::make_shared<PrivateBuffer>(100, 0x1000);
for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16});
Check p;
auto f = info(3);
p.run(0, r, f, false);
auto accesses = p.mapped()->reads;
p.run(0, r, f, true); assert(p.mapped()->reads == accesses);
r.buffers[100].second++; p.run(0, r, f, true);
auto equivalent = std::make_shared<PrivateBuffer>(101, 0x0ff0);
r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true);
r.buffers[1].second++; p.run(0, r, f, false);
auto mixed = f;
mixed.bindings.push_back({Iridium::BindingType::Texture, 999});
mixed.bindings.push_back({Iridium::BindingType::Sampler, 999});
p.run(0, r, mixed, true);
p.run(1, r, info(4, 20), false);
accesses = p.mapped()->reads;
p.run(0, r, f, true); p.run(1, r, info(4, 20), true);
assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys
for (size_t i = 0; i < 5; ++i) {
r.buffers[20].second++; p.run(1, r, info(4, 20), false);
}
accesses = p.mapped()->reads;
p.run(0, r, f, true);
assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback
Check padding;
padding.run(0, r, info(1), false);
r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())};
accesses = padding.mapped()->reads;
padding.run(0, r, info(2), true); // odd -> even reads real padding
assert(padding.mapped()->reads == accesses + 1);
r.buffers[1].second++; padding.run(0, r, info(2), false);
accesses = padding.mapped()->reads;
padding.run(0, r, info(1), true); // even -> odd needs only cached prefix
assert(padding.mapped()->reads == accesses);
padding.run(0, r, info(3), false);
Check large;
for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) {
large.run(0, r, info(count), false);
large.run(0, r, info(count), true);
}
r.buffers[64].second++; large.run(0, r, info(65), false);
large.run(1, r, info(8), false, nullptr, false);
large.run(1, r, info(8), true);
Check missing;
FunctionResources empty;
missing.run(0, empty, info(2, 999), false);
missing.run(0, empty, info(2, 999), true);
missing.device->fallback++; missing.run(0, empty, info(2, 999), false);
ResolvedFunctionResources resolved;
resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get();
missing.run(1, r, info(1), false, &resolved);
missing.run(1, r, info(1), true, &resolved);
resolved.buffers[0].value = nullptr;
missing.run(1, r, info(1), false, &resolved);
Check roll;
roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false);
auto oldChunk = roll.pool.chunk;
roll.pool.used = 256*1024;
r.buffers[0].second++; roll.run(0, r, info(2), false);
assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2);
// Old offset 256 must not supply a stale key for untouched new-chunk bytes.
roll.tables[1] = {roll.tables[0].buffer, 256, 16};
roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true);
enabled = false; roll.run(1, r, info(2, 3), false); enabled = true;
Check guard;
guard.run(0, r, info(0), false); guard.run(0, r, info(0), true);
auto id = guard.tables[0].buffer;
guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true);
guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false);
accesses = guard.mapped()->reads;
guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false);
assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read
guard.tables[0].buffer += 100; guard.run(0, r, info(1), false);
guard.tables[0].range = 32; guard.run(0, r, info(1), false);
}
+99
View File
@@ -0,0 +1,99 @@
"""Select the automatic client-in-game mode from LCULocalPreferences.yaml."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py')
tricks = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tricks)
class ClientInGameTricksTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml'
def write(self, optimization=None, close=None):
lines = ['lol-user-experience:', ' data:']
if optimization is not None:
lines.append(f' potatoModeEnabled: {optimization}')
if close is not None:
lines.append(f' closeLeagueClientDuringGame: {close}')
self.path.write_text('\n'.join(lines) + '\n')
def test_close_when_both_preferences_are_true(self):
self.write(True, True)
self.assertEqual(tricks.mode(self.path), 'close')
def test_hide_when_optimizations_are_on_and_close_is_false(self):
self.write(True, False)
self.assertEqual(tricks.mode(self.path), 'hide')
def test_none_when_close_preference_is_missing_or_invalid(self):
self.write(True)
self.assertEqual(tricks.mode(self.path), 'none')
for value in ('maybe', '"true', 'true#note', '"true"junk'):
self.write(True, value)
self.assertEqual(tricks.mode(self.path), 'none', value)
def test_none_when_optimizations_are_off(self):
self.write(False, True)
self.assertEqual(tricks.mode(self.path), 'none')
def test_none_when_optimizations_are_missing_or_file_is_unreadable(self):
self.write(None, True)
self.assertEqual(tricks.mode(self.path), 'none')
self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none')
def test_last_duplicate_value_wins_and_quotes_are_accepted(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: false\n'
' closeLeagueClientDuringGame: false\n'
' potatoModeEnabled: "true"\n'
' closeLeagueClientDuringGame: "true"\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_keys_outside_the_named_section_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: false\n'
'other-section:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: true\n')
self.assertEqual(tricks.mode(self.path), 'hide')
def test_inline_yaml_comments_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true # client optimizations\n'
' closeLeagueClientDuringGame: "true" # close during matches\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_second_read_failure_fails_closed(self):
self.write(True, False)
text = self.path.read_text()
with patch.object(Path, 'read_text', side_effect=[text, OSError()]):
self.assertEqual(tricks.mode(self.path), 'none')
def test_main_prints_mode(self):
self.write(True, False)
output = io.StringIO()
with patch('sys.stdout', output):
self.assertEqual(tricks.main([str(self.path)]), 0)
self.assertEqual(output.getvalue().strip(), 'hide')
if __name__ == '__main__':
unittest.main()
+346
View File
@@ -0,0 +1,346 @@
"""Exercise the in-match client window hide with fake /proc entries and X11."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py')
hide_in_game = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hide_in_game)
GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends'
MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx'
RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices'
class FakeX11:
def __init__(self):
self.unmapped = []
self.shown = []
def unmap(self, window):
self.unmapped.append(window)
def show(self, window):
self.shown.append(window)
class HideClientInGameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / '.darling'
self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)})
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
def environment(self, prefix):
return b'%s=%s\0' % (
hide_in_game.SOCK_VARIABLE,
os.fsencode(prefix / hide_in_game.SOCK_NAME),
)
def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'cmdline').write_bytes(
executable.encode() + b'\0' +
b''.join(arg.encode() + b'\0' for arg in args))
if environ is None:
environ = self.environment(self.prefix if prefix == 'own' else prefix)
(folder / 'environ').write_bytes(environ)
(folder / 'stat').write_text(f'{pid} (game) S')
return folder
def test_game_running_for_own_prefix(self):
self.process(123)
self.assertTrue(hide_in_game.game_running(self.prefix, self.proc))
def test_no_game_is_false(self):
self.process(123, executable=MAIN)
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_of_another_prefix_is_false(self):
self.process(123, prefix=self.root / 'other')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_without_socket_is_false(self):
self.process(123, environ=b'')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_unreadable_environment_is_unknown(self):
folder = self.process(123)
original = Path.read_bytes
def read(path):
if path.name == 'environ':
raise PermissionError()
return original(path)
with patch.object(Path, 'read_bytes', read):
self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc))
def test_unreadable_process_directory_is_unknown(self):
class UnreadableProc:
def iterdir(self):
raise OSError()
self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc()))
def test_host_socket_for_guest_uses_nspid(self):
host = self.proc / '999'
host.mkdir()
(host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n')
(host / 'environ').write_bytes(self.environment(self.prefix))
self.assertEqual(
hide_in_game.host_socket_for_guest(5877, self.proc),
os.fsencode(self.prefix / hide_in_game.SOCK_NAME),
)
def test_window_belongs_to_prefix(self):
socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME)
with patch.object(hide_in_game, 'window_pid', return_value=5877), \
patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket):
self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix))
self.assertFalse(hide_in_game.window_belongs_to_prefix(
'0x10', self.root / 'other'))
def test_once_runs_without_opt_in(self):
output = io.StringIO()
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}), patch.object(hide_in_game, 'target_prefix',
return_value=self.prefix), \
patch.object(hide_in_game, 'game_running', return_value=True), \
patch.object(hide_in_game.sys, 'stdout', output):
self.assertEqual(hide_in_game.main(['--once']), 0)
self.assertIn('match=running', output.getvalue())
def test_target_windows_ignores_game_class(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20', '0x30']), \
patch.object(hide_in_game, 'window_class',
side_effect=[
'wm_class(string) = "leagueclientux"',
'wm_class(string) = "leagueoflegends"',
'wm_class(string) = "riot client"',
]), \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30])
def test_target_windows_requires_this_prefix(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"'), \
patch.object(hide_in_game, 'window_belongs_to_prefix',
side_effect=[True, False]):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10])
def test_target_windows_caches_non_empty_classes(self):
cache = {}
with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"') as classes, \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(classes.call_count, 1)
def test_missing_wm_class_is_not_cached_as_a_class(self):
with patch.object(hide_in_game, 'xprop_value',
return_value='WM_CLASS: not found.'):
self.assertEqual(hide_in_game.window_class('0x10'), '')
def test_unmap_failure_removes_the_window_from_hidden(self):
class FailingX11(FakeX11):
def unmap(self, window):
raise RuntimeError('unmap failed')
watcher = hide_in_game.Watcher(
self.prefix, FailingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, set())
def test_interrupted_unmap_keeps_the_window_tracked(self):
shown = []
class InterruptingX11:
def unmap(self, window):
raise KeyboardInterrupt
def show(self, window):
shown.append(window)
watcher = hide_in_game.Watcher(
self.prefix, InterruptingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
with self.assertRaises(KeyboardInterrupt):
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
watcher.restore()
self.assertEqual(shown, [0xabc])
def test_log_failure_does_not_discard_an_unmapped_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
watcher = hide_in_game.Watcher(
self.prefix, FakeX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
def test_restore_log_failure_still_shows_the_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher.hidden = {0xabc}
watcher._show_all('test')
self.assertEqual(x11.shown, [0xabc])
self.assertEqual(watcher.hidden, set())
def test_idle_timeout_leaves_after_client_disappears(self):
states = iter([True, False, False])
windows = iter([[0xabc], [], []])
times = iter([0.0, 10.0, 700.0])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: next(windows),
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
clock=lambda: next(times),
)
self.assertEqual(watcher.run(max_steps=3), 0)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('no client or match', output.getvalue())
def test_hides_then_shows_on_match_end(self):
states = iter([True, True, False])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=3)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('hidden 0xabc', output.getvalue())
self.assertIn('the match ended', output.getvalue())
self.assertIn('shown 0xabc', output.getvalue())
def test_unreadable_state_restores_the_window(self):
states = iter([True, None])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xdef],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=2)
self.assertEqual(x11.unmapped, [0xdef])
self.assertEqual(x11.shown, [0xdef])
self.assertIn('unreadable /proc state', output.getvalue())
def test_no_game_does_not_touch_windows(self):
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: False,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher.run(max_steps=1)
self.assertEqual(x11.unmapped, [])
self.assertEqual(x11.shown, [])
def test_main_is_disabled_by_default(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_close_mode_takes_precedence(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '1',
'RCLOL_CLOSE_CLIENT_IN_GAME': '1',
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_helper_starts_after_the_installed_client_check(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertLess(source.index('Riot Client is not installed in'),
source.index('RCLOL_CLIENT_IN_GAME_TRICKS'))
def test_master_tricks_switch_enables_hide(self):
class FakeX11:
def close(self):
pass
watcher = Mock()
with patch.dict(os.environ, {
'RCLOL_CLIENT_IN_GAME_TRICKS': '1',
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}), patch.object(hide_in_game, 'target_prefix',
return_value=self.prefix), \
patch.object(hide_in_game, 'X11', return_value=FakeX11()), \
patch.object(hide_in_game, 'Watcher', return_value=watcher):
self.assertEqual(hide_in_game.main([]), 0)
watcher.run.assert_called_once_with()
watcher.restore.assert_called_once_with()
if __name__ == '__main__':
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
"""Compile the shipped address-table helper against an inert CPU-only fixture."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HEADER = 'private-include/indium/command-encoder.private.hpp'
def address_helper():
patch = (ROOT / 'patches/metal--deps--indium.patch').read_text()
marker = f'diff --git a/{HEADER} b/{HEADER}\n'
section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0]
# Extract each postimage hunk separately: never silently join a source gap.
hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:]
postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True)
if line.startswith((' ', '+'))) for hunk in hunks]
def region(start, end):
matches = [text[text.index(start):text.index(end, text.index(start))]
for text in postimages if start in text and end in text]
if len(matches) != 1:
raise AssertionError('expected one complete patched region: ' + start)
return matches[0]
return (region(' template<class T, size_t capacity = 64> class DescriptorScratch',
' // Per-encoder binding tables') +
region('\tstruct AddressPool {', '\n\ttemplate<size_t setCount>'))
@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required')
class AddressShadowChecks(unittest.TestCase):
def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self):
fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text()
with tempfile.TemporaryDirectory() as folder:
path = Path(folder)
source = path / 'check.cpp'
source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper()))
build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')],
capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stderr)
result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Resolve the client-in-game mode from League's local preferences.
The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads
`lol-user-experience` from LCULocalPreferences.yaml:
- potatoModeEnabled must be true (client optimizations enabled);
- closeLeagueClientDuringGame true -> close the client UI while a match runs;
- closeLeagueClientDuringGame false -> hide the client windows instead;
- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action
(none).
Only the named `lol-user-experience` section is considered, so identical keys
in another section cannot select a helper. This helper only selects a mode; it
does not change preferences or files.
"""
import sys
from pathlib import Path
SECTION = 'lol-user-experience'
CLOSE_KEY = 'closeLeagueClientDuringGame'
OPTIMIZATION_KEY = 'potatoModeEnabled'
TRUE_VALUES = {'true', 'yes', '1'}
FALSE_VALUES = {'false', 'no', '0'}
def parse_bool(value):
"""Return True/False/None for one YAML scalar, ignoring a trailing comment."""
value = value.strip()
if not value:
return None
if value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return None
scalar = value[1:end]
remainder = value[end + 1:]
if remainder and not remainder[0].isspace():
return None
remainder = remainder.strip()
if remainder and not remainder.startswith('#'):
return None
else:
scalar = value
for index, char in enumerate(scalar):
if char == '#' and (index == 0 or scalar[index - 1].isspace()):
scalar = scalar[:index].rstrip()
break
scalar = scalar.lower()
if scalar in TRUE_VALUES:
return True
if scalar in FALSE_VALUES:
return False
return None
def read_bool(path, key, section=SECTION):
"""Return True/False for the last key in section, or None when invalid.
OSError/UnicodeError is raised for an unreadable file so callers can fail
closed instead of treating a read race as a missing preference.
"""
lines = Path(path).read_text().splitlines()
in_section = False
value = None
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if not line.startswith((' ', '\t')):
if stripped == section + ':':
in_section = True
elif in_section:
break
continue
if not in_section or not stripped.startswith(key + ':'):
continue
value = parse_bool(stripped.split(':', 1)[1])
return value
def mode(path):
try:
optimization = read_bool(path, OPTIMIZATION_KEY)
except (OSError, UnicodeError):
return 'none'
if optimization is not True:
return 'none'
try:
close = read_bool(path, CLOSE_KEY)
except (OSError, UnicodeError):
return 'none'
if close is True:
return 'close'
if close is False:
return 'hide'
return 'none'
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
path = argv[0] if argv else ''
print(mode(path))
return 0
if __name__ == '__main__':
sys.exit(main())
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Opt-in: hide the League client UI while a match is running.
The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when
the client's own close-during-game preference is false. The low-level
`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It
hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows
them again when the game process exits. The Riot client, League backend, CEF
helpers and game keep running.
This is launcher/window management only. It does not patch client binaries,
CEF, anti-cheat or DRM, and it never hides the game window. It is off by
default, fails closed, and is intended for X11/Xwayland desktops.
The game is attributed to this prefix through the loader's
`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc,
ownership or window information cannot be read, no window is touched.
Diagnose one observation with:
python3 tools/hide-league-client-in-game.py --once
Enable it for a launcher run with:
RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh
"""
import argparse
import ctypes
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
GAME_NAME = 'LeagueofLegends'
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
POLL_SECONDS = 2.0
IDLE_SECONDS = 600
TARGET_CLASSES = ('leagueclientux', 'riot client')
def target_prefix():
"""The prefix this launcher owns, or None when it cannot be determined."""
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def process_state(proc):
try:
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
except (OSError, IndexError):
return None
def command_names(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [
os.path.basename(part.decode(errors='replace'))
for part in raw.split(b'\0')
if part
]
def game_running(prefix, proc_root=Path('/proc')):
"""True/False for a game of this prefix; None when /proc is unreliable.
The game executable is matched by its exact file name. The process must
carry the loader socket of this prefix, so a game from another install is
never considered. Any unreadable candidate makes the result None (fail
closed).
"""
owner = (prefix / SOCK_NAME).parent.resolve()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit() or process_state(proc) in ('Z', None):
continue
try:
if proc.stat().st_uid != os.getuid():
continue
if GAME_NAME not in command_names(proc):
continue
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
socket = environment.get(SOCK_VARIABLE)
if socket is None:
continue
if Path(os.fsdecode(socket)).parent.resolve() != owner:
continue
return True
except FileNotFoundError:
if proc.exists():
return None
continue
except (OSError, ValueError):
return None
return False
def xprop_root():
try:
listing = subprocess.check_output(
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return []
return re.findall(r'0x[0-9a-fA-F]+', listing)
def xprop_value(window, prop):
try:
return subprocess.check_output(
['xprop', '-id', window, prop], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return ''
def window_class(window):
value = xprop_value(window, 'WM_CLASS').lower()
return value if '=' in value else ''
def window_pid(window):
match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID'))
return int(match.group(1)) if match else None
def _socket_from_process(proc):
try:
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
except (OSError, ValueError):
return None
return environment.get(SOCK_VARIABLE)
def host_socket_for_guest(guest_pid, proc_root=Path('/proc')):
"""Find the host process for a guest X11 PID and return its loader socket.
Darling's X11 window stores the guest PID in _NET_WM_PID, while the
process visible in /proc is the host loader. NSpid carries both IDs. The
direct /proc/<pid> case is checked too for a native process.
"""
direct = proc_root / str(guest_pid)
if direct.exists():
socket = _socket_from_process(direct)
if socket is not None:
return socket
sockets = set()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit():
continue
try:
status = (proc / 'status').read_text()
except OSError:
continue
for line in status.splitlines():
if not line.startswith('NSpid:'):
continue
if str(guest_pid) not in line.split()[1:]:
continue
socket = _socket_from_process(proc)
if socket is not None:
sockets.add(socket)
return sockets.pop() if len(sockets) == 1 else None
def window_belongs_to_prefix(window, prefix):
pid = window_pid(window)
if pid is None:
return False
socket = host_socket_for_guest(pid)
if socket is None:
return False
try:
return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve()
except OSError:
return False
def target_windows(prefix, class_cache=None):
"""Managed client windows for this prefix; the game window is never selected.
WM_CLASS is cached only when non-empty. The cache is pruned when a window
leaves _NET_CLIENT_LIST; empty results are retried because a class can be
set after the window appears.
"""
current = []
for raw in xprop_root():
try:
current.append((int(raw, 16), raw))
except ValueError:
continue
current_ids = {window for window, _raw in current}
if class_cache is not None:
for stale in set(class_cache) - current_ids:
del class_cache[stale]
found = []
for window, raw in current:
classes = class_cache.get(window) if class_cache is not None else None
if not classes:
classes = window_class(raw)
if classes and class_cache is not None:
class_cache[window] = classes
if not classes or not any(name in classes for name in TARGET_CLASSES):
continue
if window_belongs_to_prefix(raw, prefix):
found.append(window)
return sorted(set(found))
class X11:
def __init__(self):
self.lib = ctypes.CDLL('libX11.so.6')
self.lib.XOpenDisplay.restype = ctypes.c_void_p
self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p]
error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
self._error_handler = error_type(lambda _display, _event: 0)
self.lib.XSetErrorHandler.argtypes = [error_type]
self.lib.XSetErrorHandler.restype = ctypes.c_void_p
self.lib.XSetErrorHandler(self._error_handler)
self.display = self.lib.XOpenDisplay(None)
if not self.display:
raise RuntimeError('cannot open DISPLAY')
def unmap(self, window):
self.lib.XUnmapWindow(self.display, window)
self.lib.XSync(self.display, 0)
def show(self, window):
self.lib.XMapRaised(self.display, window)
self.lib.XSync(self.display, 0)
def close(self):
self.lib.XCloseDisplay(self.display)
class Watcher:
"""Hide while a match is running, restore on end, uncertainty or exit."""
def __init__(self, prefix, x11, window_source=target_windows,
state_source=game_running, sleep=time.sleep, out=sys.stdout,
clock=time.monotonic):
self.prefix = prefix
self.x11 = x11
self.window_source = window_source
self.state_source = state_source
self.sleep = sleep
self.out = out
self.clock = clock
self.hidden = set()
self.class_cache = {}
self.reported_no_window = False
self.seen = False
self.idle_since = None
def _log(self, message, stream=None):
try:
print(message, file=self.out if stream is None else stream, flush=True)
except Exception:
# A closed launcher pipe must not prevent window restoration.
pass
def _show_all(self, reason):
if not self.hidden:
return
self._log(f'hide-league-client-in-game: {reason}; showing the client')
for window in sorted(self.hidden):
try:
self.x11.show(window)
except Exception as error:
self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: shown 0x{window:x}')
self.hidden.clear()
def _hide_new(self):
windows = self.window_source(self.prefix, self.class_cache)
if not windows and not self.hidden and not self.reported_no_window:
self._log('hide-league-client-in-game: no managed client window; not hiding')
self.reported_no_window = True
for window in windows:
if window in self.hidden:
continue
self.hidden.add(window)
try:
self.x11.unmap(window)
except Exception as error:
self.hidden.discard(window)
self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: hidden 0x{window:x}')
def _windows_present(self):
try:
return bool(self.window_source(self.prefix, self.class_cache))
except Exception:
# Uncertainty must not turn into an automatic exit.
return True
def run(self, max_steps=None):
steps = 0
while True:
now = self.clock()
state = self.state_source(self.prefix)
if state is None:
self._show_all('unreadable /proc state')
elif state:
self.seen = True
self.idle_since = None
if not self.hidden and self.reported_no_window:
self.reported_no_window = False
self._hide_new()
else:
self._show_all('the match ended')
self.reported_no_window = False
if self._windows_present():
self.seen = True
self.idle_since = None
elif self.seen:
if self.idle_since is None:
self.idle_since = now
if now - self.idle_since >= IDLE_SECONDS:
self._log('hide-league-client-in-game: no client or match for '
f'{IDLE_SECONDS}s: leaving')
return 0
steps += 1
if max_steps is not None and steps >= max_steps:
return 0
self.sleep(POLL_SECONDS)
def restore(self):
self._show_all('exiting')
def main(argv=None):
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--detach', action='store_true',
help='leave the launcher session so a terminal hang-up cannot end it')
parser.add_argument('--once', action='store_true',
help='one game observation and exit (no window changes)')
args = parser.parse_args(argv)
tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1'
if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1':
print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence',
file=sys.stderr, flush=True)
return 0
if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1':
print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or '
'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True)
return 0
prefix = target_prefix()
if prefix is None:
print('hide-league-client-in-game: unusable DPREFIX: no action',
file=sys.stderr, flush=True)
return 1
if args.once:
state = game_running(prefix)
print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}',
flush=True)
return 0
if args.detach:
try:
os.setsid()
except OSError:
pass
try:
x11 = X11()
except Exception as error:
print(f'hide-league-client-in-game: cannot open X11: {error}',
file=sys.stderr, flush=True)
return 1
watcher = Watcher(prefix, x11)
def stop(_signum, _frame):
raise KeyboardInterrupt
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(signum, stop)
optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks
else 'RCLOL_HIDE_CLIENT_IN_GAME=0')
print('hide-league-client-in-game: watching %s for a running match; '
'opt out with %s' % (prefix, optout), flush=True)
try:
watcher.run()
except KeyboardInterrupt:
pass
finally:
watcher.restore()
x11.close()
return 0
if __name__ == '__main__':
sys.exit(main())
+27 -7
View File
@@ -25,13 +25,6 @@ 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
# 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 &
fi
# The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN
# AAAA lookups are suppressed; set this to 0 to retain the upstream resolver.
export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"
@@ -79,6 +72,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.