diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 9594dc3..b1b6e19 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -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' diff --git a/README.md b/README.md index f9154eb..6dc7f33 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/SETUP.md b/docs/SETUP.md index cdcdbc8..70e4e71 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 571dce4..f4a5488 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f3c149f..f6c73e5 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -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 #include #include -@@ -17,117 +18,295 @@ +@@ -17,117 +18,330 @@ #include @@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644 + struct AddressPool { + std::shared_ptr 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 addresses; ++ }; ++ std::array 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(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(addressPool.chunk.get())->buffer() && -+ !memcmp(static_cast(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(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t)); ++ if (previous) addressPool.rememberAddresses(static_cast(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(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; }; diff --git a/tests/fixtures/indium-address-shadow.cpp b/tests/fixtures/indium-address-shadow.cpp new file mode 100644 index 0000000..4d10ebd --- /dev/null +++ b/tests/fixtures/indium-address-shadow.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +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 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 acquireScratchChunk() { ++chunks; return std::make_shared(chunks, chunks << 20); } +}; +struct FunctionResources { std::vector, size_t>> buffers; }; +struct Resolved { PrivateBuffer* value = nullptr; }; +struct ResolvedFunctionResources { std::vector buffers; }; +struct Binding { Iridium::BindingType type; size_t index; }; +struct FunctionInfo { std::vector bindings; }; + +/* ACTUAL_PATCHED_HELPER */ + +struct Check { + AddressPool pool; + std::shared_ptr device = std::make_shared(); + std::vector> keep; + std::array tables{}; + Check() { keep.reserve(8); } + PrivateBuffer* mapped() { return static_cast(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(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) + sizeof(size_t) + 1080); + FunctionResources r; + auto resource = std::make_shared(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(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); +} diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py new file mode 100644 index 0000000..752435b --- /dev/null +++ b/tests/test_client_in_game_tricks.py @@ -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() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py new file mode 100644 index 0000000..867f2f3 --- /dev/null +++ b/tests/test_hide_league_client_in_game.py @@ -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() diff --git a/tests/test_indium_address_shadow.py b/tests/test_indium_address_shadow.py new file mode 100644 index 0000000..1a8e886 --- /dev/null +++ b/tests/test_indium_address_shadow.py @@ -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 DescriptorScratch', + ' // Per-encoder binding tables') + + region('\tstruct AddressPool {', '\n\ttemplate')) + + +@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() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py new file mode 100755 index 0000000..2d4330c --- /dev/null +++ b/tools/client-in-game-tricks.py @@ -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()) diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py new file mode 100755 index 0000000..f9135af --- /dev/null +++ b/tools/hide-league-client-in-game.py @@ -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/ 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()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 34e7e06..bf1d067 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -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.