diff --git a/README.md b/README.md index dd0abc25f..ea1242bb2 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,4 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp index 73be35b59..04a37b720 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_alpha.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp index 6c233c16c..9a4e15ddf 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp index 299e22cf7..d9413e647 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_beta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp index 1d1e77a56..7d259f9e2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp index f9ff4b4f8..54acb1a0f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_chain.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp index 4324c80bc..4bab527fd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp index b72eb2287..c93275486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_common.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp index 1f6b20983..c5ab3e0ad 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp index ddba102d5..cf9b31bfa 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_delta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp index 8d8232675..e40ffe7bd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp index 78ddbccc7..0af479be8 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp @@ -1,9 +1,9 @@ // See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless // Scaling frame-generation chain. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to -// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk -// (PancakeTAS). Bannerlator is GPL-3.0. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. #include "lsfg_dll.h" #include "lsfg_dxbc.h" @@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) { return status; } -Variant dllVariant(const std::string& dllPath, bool preferFp16) { - if (dllPath.empty()) return Variant::None; - - PeImage img; - if (!img.open(dllPath)) return Variant::None; - - ResourceTable table; - if (parseResources(img, table) != DllStatus::Ok) return Variant::None; - - Variant v = selectVariant(table, preferFp16); - if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated; - return v; -} - DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) { if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled; @@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b return DllStatus::Ok; } -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches) { - outMatches = false; - if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable; - - CacheHeader header{}; - if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable; - - PeImage img; - if (!img.open(dllPath)) return DllStatus::NotInstalled; - - outMatches = header.sourceSize == (uint64_t)img.size() - && header.sourceHash == fnv1a64(img.data(), img.size()); - return DllStatus::Ok; -} - DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) { outVariant = Variant::None; CacheHeader header{}; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h index 72a862b58..74d6ca83d 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h @@ -7,7 +7,7 @@ // ship as RCDATA resources inside Lossless.dll from the user's own paid copy // of Lossless Scaling (Steam app 993090). Every implementation - upstream // lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their -// own DLL and extracts the resources on device. Bannerlator does the same, and +// own DLL and extracts the resources on device. DroidDeck does the same, and // bundles nothing. // // The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a @@ -26,9 +26,9 @@ // Both producers fill the same shader-id -> SPIR-V words map, so everything // downstream of this file is source-agnostic. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is -// credited to Camille LaVey / the Eden Emulator Project, and which follows -// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. // ============================================================================ #include @@ -98,18 +98,11 @@ const char* variantName(Variant v); // reported as valid - the translator handles it. DllStatus validateDll(const std::string& dllPath); -// Which producer would be used for this DLL. -Variant dllVariant(const std::string& dllPath, bool preferFp16); - // Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath // (via temp file + rename, so a failed build cannot leave a half-written // cache behind). Records which producer ran in the cache header. DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16); -// Is the cache current for this DLL? Compares source size + content hash. -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches); - // Load a previously built cache. DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet); diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp index db23e3686..c8084119f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp @@ -1,9 +1,10 @@ // See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator. // // Follows upstream lsfg-vk's src/extract/trans.cpp, including its -// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp -// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored -// under cpp/thirdparty/dxbc. +// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and +// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator +// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, +// vendored under cpp/thirdparty/dxbc. #include "lsfg_dxbc.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp index 4f1482eb7..ba9e8a021 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp @@ -1,7 +1,8 @@ // See lsfg_engine.h. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. #include "lsfg_engine.h" @@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // nothing. Copy only when generating, on the one priming frame that // precedes generation, or while the governor is warming up towards a // probe - so the ring is populated by the time it is needed. - const bool needHistory = generations > 0 || primeHistory_ + const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame() || (governorEnabled_ && governor_.wantsHistory()); if (needHistory) { copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height}); @@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // regardless of the game's own settings, and it fed straight back into the // governor: the chain made the source rate collapse, the governor saw the // collapse and refused to generate, and refusing did not stop the chain. - if (warm_ && generations > 0) chain_->DispatchShared(cmd, count); + if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count); } void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex, diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h index 20489e652..c8a5028d0 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h @@ -16,8 +16,9 @@ // unavoidable cost of interpolating rather than extrapolating; no placement of // this code changes it. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. // ============================================================================ #include @@ -109,6 +110,10 @@ private: // generation last stopped. bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; } + // With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the + // shared chain have to follow every frame it keeps warm, not just the ones that generate. + bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; } + Device device_{}; std::string cachePath_; std::unique_ptr shaders_; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp index 392930b61..574382ed7 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_gamma.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp index 8210dd259..d1cd38486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp index 1c84cb186..7be96561c 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_generate.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp index 68e23967b..0612b7422 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp index ba8447313..ab13096d6 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp @@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass return (jint)lsfg::validateDll(toStdString(env, dllPath)); } -extern "C" JNIEXPORT jint JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath, - jboolean preferFp16) { - return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath, jstring cachePath, jboolean preferFp16) { @@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, preferFp16 == JNI_TRUE); } -extern "C" JNIEXPORT jboolean JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass, - jstring cachePath, - jstring dllPath) { - bool matches = false; - const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath), - toStdString(env, dllPath), matches); - return (jboolean)(status == lsfg::DllStatus::Ok && matches); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) { lsfg::Variant variant = lsfg::Variant::None; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp index 4d1762555..ef1e11318 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_mipmaps.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp index 4ce06b0f5..d301c26ef 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp index 29da8729d..8ddf453c2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_pacer.hpp" @@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f; constexpr float SOURCE_STALE_SECONDS = 0.5f; constexpr float DISCONTINUITY_SECONDS = 0.25f; constexpr float HEADROOM_EPSILON = 0.02f; +// A target is aimed just under, so rounding never puts output past the panel's refresh rate. +constexpr float TARGET_MARGIN = 0.1f; constexpr float CREDIT_EPSILON = 1.0e-4f; constexpr float SOURCE_ACCUM_FLOOR = 0.01f; constexpr uint32_t MIN_RATE_SAMPLES = 12; @@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) { if (target_rate > 0.0f && config.refresh_rate > 0.0f) { target_rate = std::min(target_rate, config.refresh_rate); } + if (target_rate > 0.0f) target_rate -= TARGET_MARGIN; if (target_rate == 0.0f) { // No explicit target rate: the multiplier is the multiplier, full stop. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp index 5613c4c30..fe4a43ee5 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp index 96fb7bfdc..d3266d173 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_shaders.hpp" #include "lsfg_common.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp index ba7854da4..d91b00c18 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h index 0657ac975..c86d2224f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h @@ -2,10 +2,10 @@ // ============================================================================ // lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global. // -// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan +// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan // through a per-context VkTable (`vk_`), because an adrenotools-loaded driver // lives in an isolated linker namespace and shares no global symbols with the -// system loader. The LSFG chain, ported from eden by way of WinNative, calls +// system loader. The LSFG chain, ported from Eden by way of WinNative, calls // through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain // code to thread a table through every call, this shim exposes exactly the 34 // entry points the chain uses and is filled from the renderer's own table when diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 8b2509a68..63f0eed8a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -541,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { wl_shm_buffer_end_access(shm); } +/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below + * copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to + * change the cursor. A torn read is only a missed or an extra snapshot. */ +int banner_cursor_serial(void) { + return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED); +} + /* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. * Returns the number of ints written, or 0 if out is too small. */ int banner_cursor_snapshot(int *out, int cap) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 43b94987f..47a819bfd 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri static _Atomic int g_kind = VKP_FG_ENGINE_LSFG; static _Atomic int g_armed; static _Atomic int g_mult = 2; +static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */ static _Atomic int g_model = 3; static _Atomic int g_preset = 2; static _Atomic int g_cfg_dirty = 1; @@ -51,6 +52,7 @@ static struct { /* ---- engine + generation ring (compositor thread) ------------------------------------ */ static int g_engine_kind = -1; /* kind of the running engine, -1 = none */ +static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */ static int g_engine_ok; /* The engine of this kind (with this cache) could not start or build its chain; not retried * every frame - a new engine selection or a new cache clears it. */ @@ -92,15 +94,20 @@ static int64_t now_ns(void) { void vkp_framegen_set_engine(int kind) { if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG; atomic_store(&g_kind, kind); + atomic_store(&g_failed_kind, -1); atomic_store(&g_cfg_dirty, 1); } -void vkp_framegen_set_armed(int armed, int multiplier) { +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) { + if (target_fps < 0) target_fps = 0; + /* Adaptive sizes the ring and the swapchain for the most it may ever generate. */ + if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (multiplier < 2) multiplier = 2; if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1); + if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1); atomic_store(&g_armed, armed ? 1 : 0); - LOGD("framegen: set_armed(%d, x%d)", armed, multiplier); + LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps); } void vkp_framegen_set_lsfg_cache_path(const char *path) { @@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) { } static int ensure_engine(int kind) { - if (g_engine_ok && g_engine_kind == kind) return 1; + const int cache_gen = atomic_load(&g_cache_gen); + if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen)) + return 1; if (start_failed_for(kind)) return 0; if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; } char *path = NULL; @@ -293,7 +302,7 @@ static int ensure_engine(int kind) { if (g_cache_path) path = strdup(g_cache_path); pthread_mutex_unlock(&g_lock); if (kind == VKP_FG_ENGINE_LSFG && !path) { - FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)"); + FGLOG("LSFG Native can't start: no shader cache from Lossless.dll"); mark_failed(kind); return 0; } @@ -305,29 +314,39 @@ static int ensure_engine(int kind) { mark_failed(kind); return 0; } - g_engine_ok = 1; g_engine_kind = kind; + g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen; atomic_store(&g_cfg_dirty, 1); g_built_w = g_built_h = 0; FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info()); return 1; } -static int g_logged_mult; +/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */ +static const char *describe_mode(char *buf, size_t n, int mult, int target) { + if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1); + else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s"); + return buf; +} + +static int g_logged_mult, g_logged_target; static void log_arm_transition(int armed, int kind, int mult) { + const int target = atomic_load(&g_target); + char mode[96]; if (armed == g_was_armed) { - if (armed && mult != g_logged_mult) { - g_logged_mult = mult; - FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1); + if (armed && (mult != g_logged_mult || target != g_logged_target)) { + g_logged_mult = mult; g_logged_target = target; + FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target)); } return; } g_was_armed = armed; if (armed) { - g_logged_mult = mult; + g_logged_mult = mult; g_logged_target = target; float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " - "with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz); + FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " + "with the interpolated frames ahead of them", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz); } else { FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated); g_generating_logged = 0; @@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, if (atomic_exchange(&g_cfg_dirty, 0)) { float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset)); + fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz, + atomic_load(&g_model), atomic_load(&g_preset)); } if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) { if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) { @@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, 0, 0, NULL, 0, NULL, nb, bars); if (!g_generating_logged) { g_generating_logged = 1; - FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)", - fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s", + char mode[96]; + FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h, fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : ""); } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index 2d4d6d99a..b60229cdb 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 }; /* ---- app controls (any thread) ------------------------------------------------------- */ void vkp_framegen_set_engine(int kind); -/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */ -void vkp_framegen_set_armed(int armed, int multiplier); -/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */ +/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs); + * armed = 0 stops generating (the ring and the engine stay for a re-arm). */ +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps); +/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */ void vkp_framegen_set_lsfg_cache_path(const char *path); /* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 1d9d29468..35e160edc 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -197,11 +197,11 @@ int fge_unavailable(void) { return g_lsfg && g_lsfg->unavailable() ? 1 : 0; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset) { +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset) { if (multiplier < 2) multiplier = 2; if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale); - else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz); + else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz); } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.h b/app/src/main/cpp/waylandcomp/src/framegen_engine.h index ecc9e30d1..b0deec70b 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.h @@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path); void fge_stop(void); /* The engine came up but later found it cannot generate (chain build failed). */ int fge_unavailable(void); -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset); +/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */ +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset); /* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same * (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */ int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index 45bd27dff..dd4144d3e 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; } int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; } void fge_stop(void) {} int fge_unavailable(void) { return 1; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) { - (void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model, + int perf_preset) { + (void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; } uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c index c693ad25c..8d5bad571 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c @@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz, - jboolean armed, jint multiplier) { + jboolean armed, jint multiplier, jint targetFps) { (void)env; (void)clazz; - vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier); + vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps); } JNIEXPORT void JNICALL diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 10283f570..414ca45b9 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -6,6 +6,7 @@ */ #include #include +#include #include #include #include @@ -25,6 +26,7 @@ extern int banner_wayland_run(void); extern void banner_wayland_send_pointer(int action, int x, int y); extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); extern int banner_cursor_snapshot(int *out, int cap); +extern int banner_cursor_serial(void); extern void banner_wayland_send_key(int evdev, int state); extern void banner_wayland_send_scene_input(int type, int a, int b); extern void banner_wayland_vsync(int64_t frame_time_ns); @@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( return n; } +/* The serial nativeCursorSnapshot would return, without copying anything. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + return banner_cursor_serial(); +} + /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( @@ -670,3 +679,29 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) { banner_host_text_delete(before, after); } + +/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the + * device's, not this process's: it outlives the fd and the app, so the caller clears it at stop + * and again at every app start. No root; the kernel's thermal limits still apply. */ +extern void adrenotools_set_turbo(bool turbo); +JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) { + (void)env; (void)clazz; + adrenotools_set_turbo(on == JNI_TRUE); +} + +/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering + * it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between + * scheduling groups by priority, and a guest process must stay where Android put the app. Returns + * the nice value it is left at, or 100 when it could not be read. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) { + (void)env; (void)clazz; + errno = 0; + int before = getpriority(PRIO_PROCESS, (id_t)tid); + if (before == -1 && errno != 0) return 100; + if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice); + errno = 0; + int after = getpriority(PRIO_PROCESS, (id_t)tid); + return after == -1 && errno != 0 ? 100 : after; +} diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 0b52f2712..7cc6db7d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -3,10 +3,12 @@ package com.droiddeck.launcher import android.app.Application import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler +import com.droiddeck.launcher.session.GpuClockPin /** * Process-wide setup: the crash handler, so a session's folder is finished even when we die, and - * the socket Flatpak's sandboxes are started through (BwrapSpawner). + * the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's + * sandboxes are started through (BwrapSpawner). */ class App : Application() { override fun onCreate() { @@ -15,6 +17,7 @@ class App : Application() { // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) + GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4ef672e72..8c82ed818 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.Manifest import android.app.ActivityOptions import android.content.Intent @@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.toArgb import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.TurnipDriver -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller @@ -100,6 +101,7 @@ class MainActivity : ComponentActivity() { private var percent by mutableIntStateOf(-1) private var failed by mutableStateOf(false) private var frameGenLabel by mutableStateOf("Off") + private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) private var showNonAdreno by mutableStateOf(null) private var glThread by mutableStateOf(true) @@ -133,6 +135,7 @@ class MainActivity : ComponentActivity() { private var zinkLazy by mutableStateOf(false) private var noXalia by mutableStateOf(true) private var gamescopeRealtime by mutableStateOf(false) + private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) @@ -160,6 +163,9 @@ class MainActivity : ComponentActivity() { private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) } } + private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) @@ -169,6 +175,26 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) } + /** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + ui.post { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + ui.post { + lossless = state + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + } + }, "lossless-import").start() + } + /** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */ private fun importSaves(name: String, game: () -> GameSaves.Game) { if (SessionState.running) { @@ -238,6 +264,7 @@ class MainActivity : ComponentActivity() { private var theme by mutableStateOf("graphite") private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) private var hdrOn by mutableStateOf(false) + private var fpsLimit by mutableStateOf(0) private var hdrReason by mutableStateOf(null) private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) @@ -320,6 +347,7 @@ class MainActivity : ComponentActivity() { updates.start() setContent { DroidDeckTheme(theme) { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { val sm = settingsMode val page: (@Composable () -> Unit)? = when { sm != null -> { { ModeSettingsHost(sm) } } @@ -338,8 +366,8 @@ class MainActivity : ComponentActivity() { offlineAccount = offlineAccount, offline = offline, frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, steamGames = steamGames, emulators = emulatorList, running = runningLabel, - frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), - lsfgReady = LsfgNative.isInstalled(this), + frameGen = FrameGen.mode(this), + lossless = lossless, pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, @@ -415,10 +443,13 @@ class MainActivity : ComponentActivity() { onInstallPackage = { id -> installPackage(id) }, onRemovePackage = { id -> removePackage(id) }, onRuntime = { onRuntimeButton() }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this, mode) frameGenLabel = FrameGen.label(this) }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. @@ -569,6 +600,7 @@ class MainActivity : ComponentActivity() { } } } + } if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } @@ -649,6 +681,7 @@ class MainActivity : ComponentActivity() { returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn()) } refreshPhantomStatus() + syncLossless() // Opening the app and coming back from a session both land here. updates.onResume() // Auto keeps this GPU's recommended driver pair installed and set: once per app start. @@ -905,7 +938,7 @@ class MainActivity : ComponentActivity() { ModeSettingsPage( ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, - hdr = hdrOn, hdrReason = hdrReason, + hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, gpuDrivers = drivers.summary(), touchMode = touchMode, suspendPolicy = suspendPolicy, @@ -938,6 +971,7 @@ class MainActivity : ComponentActivity() { onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) }, + onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps }, onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t }, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, @@ -1003,6 +1037,7 @@ class MainActivity : ComponentActivity() { clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, gamescopeRealtime = gamescopeRealtime, + gpuClockPin = gpuClockPin, prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, @@ -1011,6 +1046,7 @@ class MainActivity : ComponentActivity() { onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, + onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> @@ -1063,6 +1099,7 @@ class MainActivity : ComponentActivity() { refreshAddedGames() shapeMode = SessionPrefs.shapeMode(this) hdrOn = SessionPrefs.hdr(this, mode) + fpsLimit = SessionPrefs.fpsLimit(this, mode) hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason touchMode = SessionPrefs.touchMode(this) suspendPolicy = SessionPrefs.suspendPolicy(this, mode) @@ -1108,6 +1145,7 @@ class MainActivity : ComponentActivity() { zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) + gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2023616d3..49160f70c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.hardware.input.InputManager import android.hardware.display.DisplayManager import android.content.Intent @@ -23,6 +24,7 @@ import android.widget.FrameLayout import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -32,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.KeyboardHost @@ -169,9 +172,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) private var fillScreen by mutableStateOf(true) - private var frameGenLabel by mutableStateOf("Off") - private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF) - private var frameGenMultiplier by mutableStateOf(2) + private var lossless by mutableStateOf(Lossless.State.NONE) + private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } + private var frameGen by mutableStateOf(FrameGen.Mode.OFF) private var fexPreset by mutableStateOf("") private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) @@ -186,7 +191,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() - override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayChanged(displayId: Int) { + followPanelRefresh(displayId) + refreshSecondScreenDisplays() + } + } + + /** + * The panel's rate, followed for the rest of the session. It is read once at start, and a + * panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode + * asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and + * its games paced against a vblank that no longer came. (WinNative 53836ca9.) + */ + private fun followPanelRefresh(displayId: Int) { + if (!CompositorHost.isStarted) return + val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return + if (display.displayId != displayId) return + val hz = display.refreshRate + if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz) } private var isHomeApp by mutableStateOf(false) private var androidApps by mutableStateOf>(emptyList()) @@ -215,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) } window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - // Game-tier power policy for the whole session: the panel's fastest mode, and the OS told - // it is in gameplay. Logged so a slow device says why. - Log.i(TAG, "perf: " + PerfMode.apply(this)) + // Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap + // divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device + // says why. The cap is the session's: an activity re-created mid-session keeps it. + if (!SessionState.running) { + SessionState.fpsLimit = SessionPrefs.fpsLimit( + this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM), + ) + } + Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit)) goFullscreen() // The device's volume keys change the stream the session plays on (the relay and // PulseAudio are media playback); they are never forwarded to the guest. @@ -315,6 +343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) @@ -336,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Opening the drawer takes the controller away from the game: release its pad. androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { + syncLossless() padBridge?.releaseAll() androidx.compose.runtime.withFrameNanos { } val requested = sessionOverlay.requestFocus() @@ -359,8 +389,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { androidApps = androidApps, hudOn = hudOn, fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, - frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, - lsfgReady = LsfgNative.isInstalled(this@SessionActivity), + frameGen = frameGen, + lossless = lossless, oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, touchAuto = if (usingTouchpad()) "touchpad" else "direct", shapeMode = shapeMode, fexPreset = fexPreset, @@ -369,11 +399,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this@SessionActivity, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this@SessionActivity, mode) readPrefs() applyFrameGen() }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent( + this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, @@ -445,6 +479,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } } + } } private fun refreshHomeApp() { @@ -578,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { hudOn = SessionPrefs.hudEnabled(this) fillScreen = SessionPrefs.forceFullscreen(this) touchMode = SessionPrefs.touchMode(this) - frameGenLabel = FrameGen.label(this) - frameGenEngine = FrameGen.engine(this) - frameGenMultiplier = FrameGen.multiplier(this) + frameGen = FrameGen.mode(this) fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) oscMode = SessionPrefs.oscMode(this) @@ -775,7 +808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CompositorHost.startOrAttach( holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, - applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), + applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit, ) if (!SessionState.running) SessionEvents.record("compositor.started") // The service owns everything below the compositor. It is started whenever no session is @@ -884,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val hz = refreshHz() Thread({ val problem = FrameGen.apply(this, hz) - if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() } + val state = Lossless.state(this) + runOnUiThread { + lossless = state + if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show() + } }, "frame-gen").start() } + /** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + runOnUiThread { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + runOnUiThread { + lossless = state + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen() + } + }, "lossless-import").start() + } + // ── Session state ─────────────────────────────────────────────────────────────────────── /** Keeps the loading overlay current from the session log, and its clock moving. */ @@ -1256,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { */ private fun syncClientCursor(): Boolean { if (!CompositorHost.isStarted) return false + // Asked on every pointer move; the image is copied only when the client has changed it. + if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf) if (n < 6) return false val serial = cursorBuf[0] diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 236c17a00..b864e837b 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu import android.content.Context import android.util.Log +import com.droiddeck.launcher.R import com.droiddeck.launcher.wayland.WaylandCompositor /** @@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor * * Both engines generate on gamescope's final output inside our compositor, so they work for any * game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the - * shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor + * shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor * setter is a value store on the native side, so this can be applied before the compositor is * up and changed live from the main screen while a session runs. */ @@ -26,62 +27,77 @@ object FrameGen { private const val FLOW_SCALE_WINFG = 0.60f private const val FLOW_SCALE_LSFG = 0.80f + /** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */ + val ADAPTIVE_TARGETS = listOf(120, 90, 60) + private const val PREFS = "frame_gen" - fun engine(context: Context): String = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF - - fun multiplier(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4) - - fun set(context: Context, engine: String, multiplier: Int) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() - .putString("engine", engine).putInt("multiplier", multiplier).apply() + /** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */ + data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) { + companion object { + val OFF = Mode(ENGINE_OFF) + } } - /** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */ - fun label(context: Context): String = label(engine(context), multiplier(context)) + fun engine(context: Context): String = mode(context).engine - fun label(engine: String, multiplier: Int): String = when (engine) { - ENGINE_WINFG -> "Win-FG $multiplier×" - ENGINE_LSFG -> "LSFG $multiplier×" - else -> "Off" + fun mode(context: Context): Mode { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF + val target = prefs.getInt("target", 0) + if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target) + return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4)) + } + + fun set(context: Context, mode: Mode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply() + } + + /** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */ + fun label(context: Context): String = label(context, mode(context)) + + fun label(context: Context, mode: Mode): String = when { + mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier) + mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target) + mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier) + else -> context.getString(R.string.frame_gen_off) } /** * Pushes the saved setting into the compositor. Returns a message for the user when the - * engine cannot run (LSFG without Lossless Scaling), or null. + * engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated + * Lossless.dll is prepared. */ fun apply(context: Context, refreshHz: Float): String? { - val engine = engine(context) - val multiplier = multiplier(context) - var problem: String? = null - when (engine) { + val mode = mode(context) + when (mode.engine) { ENGINE_LSFG -> { - val status = LsfgNative.ensureCache(context) - if (status != LsfgNative.STATUS_OK) { - problem = if (status == LsfgNative.STATUS_NOT_INSTALLED) - "LSFG needs Lossless Scaling installed from the Steam client" - else "LSFG: " + LsfgNative.statusName(status) - Log.w(TAG, problem) - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + val status = Lossless.sync(context) + val cache = Lossless.cacheFile(context) + if (status != LsfgNative.STATUS_OK || cache == null) { + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + val problem = context.getString( + if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed, + ) + Log.w(TAG, "$problem (status $status)") return problem } WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG) - WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath) + WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target) } ENGINE_WINFG -> { WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG) WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0) } - else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0) + else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) } - Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz") - return problem + Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz") + return null } private const val TAG = "FrameGen" diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt new file mode 100644 index 000000000..7a153b37e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt @@ -0,0 +1,187 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import java.security.DigestOutputStream +import java.security.MessageDigest + +/** + * LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090). + * + * The copy the Steam client installs, in any library it installs to, is taken by + * itself and taken again whenever the client updates it. Once the session has seen the account + * own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes + * or another is imported. The DLL is copied in and its shaders are built into a cache named after + * its contents, so a different DLL hands the compositor a different path and its engine reloads. + * Everything here blocks: call it off the main thread. + */ +object Lossless { + enum class Source { NONE, STEAM, IMPORT } + + enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED } + + data class State(val owned: Boolean, val ready: Boolean, val source: Source) { + companion object { + val NONE = State(owned = false, ready = false, source = Source.NONE) + } + } + + private const val TAG = "Lossless" + private const val PREFS = "lossless" + private const val KEY_OWNED = "owned" + private const val KEY_ID = "id" + private const val KEY_SOURCE = "source" + private const val KEY_STEAM_STAMP = "steamStamp" + private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll" + private const val UNCHANGED = -1 + + fun state(context: Context): State { + val source = prefs(context).getString(KEY_SOURCE, null) + ?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE + return State(owned(context), cacheFile(context)?.isFile == true, source) + } + + /** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */ + fun owned(context: Context): Boolean { + if (prefs(context).getBoolean(KEY_OWNED, false)) return true + if (!ownedFlag(context).exists()) return false + markOwned(context) + return true + } + + fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) } + + /** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */ + @Synchronized + fun sync(context: Context): Int { + val steam = steamDll(context) + if (steam != null) { + markOwned(context) + val stamp = "${steam.length()}:${steam.lastModified()}" + if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) { + val status = adopt(context, steam, Source.STEAM) + if (status != LsfgNative.STATUS_OK && status != UNCHANGED) { + Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}") + } + prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply() + } + } + return ensureBuilt(context) + } + + @Synchronized + fun import(context: Context, dll: File): ImportResult { + if (!owned(context)) return ImportResult.NOT_OWNED + return when (adopt(context, dll, Source.IMPORT)) { + UNCHANGED -> ImportResult.UNCHANGED + LsfgNative.STATUS_OK -> ImportResult.UPDATED + LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE, + LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID + else -> ImportResult.FAILED + } + } + + fun message(context: Context, result: ImportResult): String = context.getString( + when (result) { + ImportResult.UPDATED -> R.string.lsfg_imported + ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged + ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned + ImportResult.INVALID -> R.string.lsfg_import_invalid + ImportResult.FAILED -> R.string.lsfg_import_failed + }, + ) + + private fun ensureBuilt(context: Context): Int { + val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED + val cache = cacheFor(context, id) + if (usable(cache)) return LsfgNative.STATUS_OK + val dll = storedDll(context) + if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED + return build(dll, cache) + } + + private fun adopt(context: Context, source: File, from: Source): Int { + val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged") + try { + val id = copyHashing(source, staged) + if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED + val cache = cacheFor(context, id) + var status = LsfgNative.nativeValidateDll(staged.path) + if (status == LsfgNative.STATUS_OK) status = build(staged, cache) + if (status != LsfgNative.STATUS_OK) { + cache.delete() + return status + } + if (!staged.renameTo(storedDll(context))) { + cache.delete() + return LsfgNative.STATUS_CACHE_UNUSABLE + } + prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply() + removeStale(context, id) + Log.i(TAG, "Lossless.dll from $source ($from) ready as $id") + return LsfgNative.STATUS_OK + } catch (e: java.io.IOException) { + Log.w(TAG, "could not copy $source", e) + return LsfgNative.STATUS_UNREADABLE_FILE + } catch (t: Throwable) { + Log.e(TAG, "could not take $source", t) + return LsfgNative.STATUS_CACHE_UNUSABLE + } finally { + staged.delete() + } + } + + private fun build(dll: File, cache: File): Int = try { + val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true) + Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" + + if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "") + status + } catch (t: Throwable) { + Log.e(TAG, "shader cache ${cache.name}", t) + LsfgNative.STATUS_CACHE_UNUSABLE + } + + private fun usable(cache: File): Boolean = cache.isFile && + runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false) + + private fun copyHashing(source: File, target: File): String { + val digest = MessageDigest.getInstance("SHA-256") + source.inputStream().use { input -> + DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) } + } + return digest.digest().take(8).joinToString("") { "%02x".format(it) } + } + + private fun removeStale(context: Context, keep: String) { + File(context.filesDir, "lsfg-native").deleteRecursively() + dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name } + ?.forEach { it.delete() } + } + + /** The newest copy in any library the client installs to: its own, the card's, the Games folders. */ + private fun steamDll(context: Context): File? { + val libraries = listOfNotNull( + File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"), + GameStorage.effective(context)?.path?.let(::File), + ) + SessionPrefs.addedGamesDirs(context).map(::File) + return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() } + } + + private fun markOwned(context: Context) { + if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply() + } + + private fun statusName(status: Int): String = + runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status") + + private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + private fun dir(context: Context) = File(context.filesDir, "lsfg") + private fun storedDll(context: Context) = File(dir(context), "Lossless.dll") + private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache") + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java index c9b03ef03..29c7dbd17 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java @@ -1,99 +1,30 @@ package com.droiddeck.launcher.gpu; -import android.content.Context; -import android.util.Log; - -import com.droiddeck.launcher.runtime.LinuxRuntime; - -import java.io.File; - /** - * LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling - * and cached on device. - * - *

The 25 compute shaders are not redistributable. They live as resources inside the user's - * paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the - * Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped - * read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth - * doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the - * DLL's size and hash. Call {@link #ensureCache} off the main thread. - * - *

Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into - * the compositor library. + * The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library): + * {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its + * DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block. */ -public final class LsfgNative { - private static final String TAG = "LsfgNative"; - +final class LsfgNative { static { System.loadLibrary("bannerwayland"); } private LsfgNative() {} // Mirrors lsfg::DllStatus. - public static final int STATUS_OK = 0; - public static final int STATUS_NOT_INSTALLED = 1; - public static final int STATUS_UNREADABLE_FILE = 2; - public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; - public static final int STATUS_MISSING_SHADERS = 4; - public static final int STATUS_TRANSLATION_FAILED = 5; - public static final int STATUS_CACHE_UNUSABLE = 6; + static final int STATUS_OK = 0; + static final int STATUS_NOT_INSTALLED = 1; + static final int STATUS_UNREADABLE_FILE = 2; + static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; + static final int STATUS_MISSING_SHADERS = 4; + static final int STATUS_TRANSLATION_FAILED = 5; + static final int STATUS_CACHE_UNUSABLE = 6; - private static native int nativeValidateDll(String dllPath); - private static native int nativeDllVariant(String dllPath, boolean preferFp16); - private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); - private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath); - private static native int nativeCacheVariant(String cachePath); - private static native String nativeStatusName(int status); - private static native String nativeVariantName(int variant); + // Mirrors lsfg::Variant. + static final int VARIANT_NONE = 0; - /** Where Lossless Scaling lands when installed from the client inside the runtime. */ - public static File losslessDll(Context context) { - return new File(LinuxRuntime.rootDir(context), - "root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"); - } - - /** Where the translated SPIR-V chain is cached. */ - public static File cacheFile(Context context) { - return new File(context.getFilesDir(), "lsfg-native/shaders.cache"); - } - - public static boolean isInstalled(Context context) { - return losslessDll(context).isFile(); - } - - /** - * Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or - * changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start. - */ - public static int ensureCache(Context context) { - File dll = losslessDll(context); - if (!dll.isFile()) return STATUS_NOT_INSTALLED; - File cache = cacheFile(context); - try { - if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) { - return STATUS_OK; - } - int status = nativeValidateDll(dll.getPath()); - if (status != STATUS_OK) { - Log.w(TAG, "Lossless.dll rejected: " + statusName(status)); - return status; - } - //noinspection ResultOfMethodCallIgnored - cache.getParentFile().mkdirs(); - status = nativeBuildCache(dll.getPath(), cache.getPath(), true); - Log.i(TAG, "cache build: " + statusName(status) - + (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : "")); - return status; - } catch (Throwable t) { - Log.e(TAG, "ensureCache", t); - return STATUS_CACHE_UNUSABLE; - } - } - - public static String statusName(int status) { - try { - return nativeStatusName(status); - } catch (Throwable t) { - return "status " + status; - } - } + static native int nativeValidateDll(String dllPath); + static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); + static native int nativeCacheVariant(String cachePath); + static native String nativeStatusName(int status); + static native String nativeVariantName(int variant); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt new file mode 100644 index 000000000..8e0c7c8a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.io.File + +/** + * The GPU held at its top clock for a session, without root: KGSL's power-control property turned + * off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the + * governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch; + * Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat, + * so it is opt-in, and the kernel's thermal limits still apply on top of it. + * + * The property belongs to the device, not to this process, and a killed app cannot unwind it: it + * is cleared when a session stops, and at the next app start when a marker file says this app set + * it and never cleared it - only then, so another app's own setting is not undone. + * (Bannerlator, PerfGpuTurbo.) + */ +object GpuClockPin { + private const val TAG = "GpuClockPin" + private const val KGSL_NODE = "/dev/kgsl-3d0" + + private const val MARKER = "gpu-clock-pinned" + + @Volatile + private var pinned = false + + private val supported: Boolean by lazy { File(KGSL_NODE).exists() } + + /** At a session's start: pins the clock when the user asked for it. The line is for the log. */ + @Synchronized + fun start(context: Context): String { + if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed" + if (!supported) return "gpu clock: no $KGSL_NODE, left governed" + return try { + File(context.filesDir, MARKER).createNewFile() + WaylandCompositor.nativeSetGpuTurbo(true) + pinned = true + "gpu clock: held at its top" + } catch (t: Throwable) { + Log.w(TAG, "pin", t) + "gpu clock: pin failed" + } + } + + /** At a session's end. */ + @Synchronized + fun stop(context: Context) { + if (pinned) clear(context) + } + + /** At the app's start: a process killed with the clock pinned left only the marker behind. */ + @Synchronized + fun clearLeftover(context: Context) { + if (File(context.filesDir, MARKER).exists()) clear(context) + } + + private fun clear(context: Context) { + if (supported) { + try { + WaylandCompositor.nativeSetGpuTurbo(false) + } catch (t: Throwable) { + Log.w(TAG, "clear", t) + } + } + pinned = false + File(context.filesDir, MARKER).delete() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index b0d49257b..a9b5b0459 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -8,7 +8,9 @@ import android.os.SystemClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.util.Locale @@ -86,20 +88,22 @@ class PerfHud(context: Context) { } private fun line(base: Float): String { - val engine = FrameGen.engine(context) - if (engine == FrameGen.ENGINE_OFF) return fps(base) - val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG" - val multiplier = FrameGen.multiplier(context) + val mode = FrameGen.mode(context) + if (mode.engine == FrameGen.ENGINE_OFF) return fps(base) + val lsfg = mode.engine == FrameGen.ENGINE_LSFG + val name = if (lsfg) "LSFG" else "Win-FG" + val label = FrameGen.label(context, mode) + if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name) val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null } val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 } return when { - problem == 1 -> "$name: unsupported" - problem == 2 -> "$name: engine failed" + problem == 1 -> context.getString(R.string.hud_fg_unsupported, name) + problem == 2 -> context.getString(R.string.hud_fg_failed, name) stats != null && stats[3] > 1f -> { val source = if (stats[2] > 1f) stats[2] else base - String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3]) + String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3]) } - else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base)) + else -> context.getString(R.string.hud_fg_starting, label, fps(base)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt index 7e99840d5..7a44423a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -17,7 +17,8 @@ import android.util.Log * Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to * the game; * - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to - * 120 Hz for the menu, not only when a game votes for it; + * 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode + * the cap divides evenly, so every capped frame is shown for the same number of refreshes; * - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases * its boost on loading screens or menus keeps it up. * @@ -27,7 +28,7 @@ import android.util.Log object PerfMode { private const val TAG = "PerfMode" - fun apply(a: Activity): String { + fun apply(a: Activity, fpsLimit: Int = 0): String { val parts = ArrayList(2) // The fastest display mode at the panel's current size. @@ -35,10 +36,12 @@ object PerfMode { val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else { val cur = display.mode - val best = display.supportedModes + val sameSize = display.supportedModes .filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight } - .maxByOrNull { it.refreshRate } - if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) { + val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate } + ?: sameSize.maxByOrNull { it.refreshRate } + if (best != null && best.modeId != cur.modeId && + (best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) { a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId } "display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})" } else "display ${cur.refreshRate.toInt()} Hz" @@ -64,4 +67,11 @@ object PerfMode { return parts.joinToString(" · ") } + + /** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */ + fun cadenceFits(hz: Float, fpsLimit: Int): Boolean { + if (fpsLimit <= 0) return true + val ratio = hz / fpsLimit + return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index fcf60de9b..515393370 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -283,6 +283,13 @@ object SessionPrefs { * plainly exist is the signature - and the fallback is to trace everything instead: slower, * but correct. Max's advice for devices whose kernels "don't work well with it". */ + /** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */ + fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false) + + fun setGpuClockPin(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gpuClockPin", on).apply() + } + fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false) fun setProotNoSeccomp(context: Context, on: Boolean) { @@ -556,6 +563,22 @@ object SessionPrefs { prefs(context).edit().putBoolean("hdr.$mode", on).apply() } + /** + * The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's + * -r (what the client and its games see as the display's rate), the compositor's buffer release + * pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest + * one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a + * 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit + * does. Applies next session. + */ + fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0) + + fun setFpsLimit(context: Context, mode: String, fps: Int) { + prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply() + } + + val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120") + /** * The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a * fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices. diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 6170b3468..5abd06c2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.input.FakeInputWriter import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -269,6 +270,8 @@ class SessionService : Service() { val sessionDir = openSessionFolder() val sessionLog = File(sessionDir, "session.log") + Log.i(TAG, GpuClockPin.start(this)) + val size = SessionState.outputSize val guest = ArrayList() // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the @@ -287,7 +290,7 @@ class SessionService : Service() { guest.add("DXVK_HDR=1") Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1") } - guest.add("BL_FPS=0") + guest.add("BL_FPS=" + SessionState.fpsLimit) guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) guest.add("BL_LOG=" + sessionLog.path) guest.add("BL_DEBUG_DIR=" + sessionDir.path) @@ -314,6 +317,7 @@ class SessionService : Service() { val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) guest.add("BL_ADDED_GAMES=" + listing.path) if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1") + if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1") if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) } // Where the guest leaves a request for another session (the desktop's Steam launchers). @@ -435,6 +439,7 @@ class SessionService : Service() { } sessionPid = pid if (pid > 1) { + raiseTracer(pid, gen) SessionEvents.guestStarted(pid) } else { SessionEvents.record("guest.start_failed", mapOf("pid" to pid)) @@ -524,6 +529,12 @@ class SessionService : Service() { // BL_STEAMDECK; it is the one that builds the command line). if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") + // Mesa's shader cache as one database instead of a file per entry. Every lookup in the + // file cache is an open and every store an open and a rename, each a proot stop (a rename + // two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is + // opened once and read and written with pread/pwrite, which proot never sees. Mesa removes + // the old folder itself once it has gone a week untouched. + guest.add("MESA_DISK_CACHE_DATABASE=1") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) @@ -964,6 +975,31 @@ class SessionService : Service() { } } + /** + * proot is one thread, and every syscall it traps anywhere in the session - a path lookup in + * Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to + * be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6, + * under the compositor's -8. After a moment, not at once: the first guest process is forked by + * proot itself and would inherit the value, and with it everything the session starts. + */ + private fun raiseTracer(pid: Int, gen: Int) { + Thread({ + try { + Thread.sleep(2000) + } catch (e: InterruptedException) { + return@Thread + } + if (gen != sessionGen || sessionPid != pid) return@Thread + val nice = try { + WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE) + } catch (t: Throwable) { + Log.w(TAG, "tracer priority", t) + return@Thread + } + Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)") + }, "tracer-priority").start() + } + private fun stopSession(status: Int) { synchronized(stopLock) { if (!SessionState.running) return @@ -972,6 +1008,7 @@ class SessionService : Service() { val stoppedGen = sessionGen SessionState.stopRequested = false SessionEvents.record("guest.exited", mapOf("status" to status)) + GpuClockPin.stop(this) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) suspendOperationPending = false launchWatcher?.stopWatching() @@ -1148,6 +1185,8 @@ class SessionService : Service() { companion object { private const val TAG = "SessionService" + /** proot's tracer: above everything in the guest, under the compositor thread's -8. */ + private const val TRACER_NICE = -6 /** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */ private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug" /** Downloads file of KEY=VALUE lines added to the session environment verbatim. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 71245d3fc..e197a6f8b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -95,6 +95,10 @@ object SessionState { @Volatile var refreshHz: Float = 60f + /** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */ + @Volatile + var fpsLimit: Int = 0 + /** The compositor has presented a frame, so the loading panel is behind us for this session. */ @Volatile var firstFrameSeen = false diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 0e5e40320..67fc47be7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -346,7 +346,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp)) + .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick), ) { @@ -369,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) - .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), @@ -411,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE) + .glideBorder(hot, ROW_SHAPE, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), @@ -440,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE) + .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index d7c90ca5f..7b6722ccd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -92,7 +92,7 @@ fun DriverPage( contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) + .glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line) .focusRequester(refreshFocus) .hoverable(refreshSrc) .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), @@ -174,7 +174,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), + .glideBorder(hot, shape, pal.signal), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -210,7 +210,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp)) + .glideBorder(delHot, RoundedCornerShape(10.dp), colors.error) .focusRequester(delFocus) .focusProperties { left = mainFocus } .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete), @@ -230,7 +230,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color, modifier = modifier.clip(shape) .background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (hot) color else Color.Transparent, shape) + .glideBorder(hot, shape, color) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt new file mode 100644 index 000000000..6779032e1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -0,0 +1,253 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.abs + +// One focus ring for a whole screen that slides from control to control. When focus moves, the +// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so +// the ring stretches like a drop and snaps into the new control's shape. + +/** A control that can show the ring: where it is and what its ring looks like there. */ +internal class GlideSource { + var coords: LayoutCoordinates? = null + var shape: Shape by mutableStateOf(androidx.compose.ui.graphics.RectangleShape) + var color by mutableStateOf(Color.Transparent) + var width by mutableStateOf(2.dp) +} + +internal class FocusGlide(val view: View) { + var host: LayoutCoordinates? = null + /** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */ + val hot = mutableStateListOf() + /** Bumped when the current control moves (a scroll, a relayout). */ + var moves by mutableIntStateOf(0) + + val current: GlideSource? get() = hot.lastOrNull() + + fun claim(s: GlideSource) { hot.remove(s); hot.add(s) } + fun release(s: GlideSource) { hot.remove(s) } + fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ } + + /** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */ + fun boundsOf(s: GlideSource): Rect? { + val h = host ?: return null + val c = s.coords ?: return null + if (!h.isAttached || !c.isAttached) return null + val b = h.localBoundingBoxOf(c, clipBounds = true) + return if (b.width < 1f || b.height < 1f) null else b + } +} + +internal val LocalFocusGlide = staticCompositionLocalOf { null } + +private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float = + when (val o = shape.createOutline(size, dir, density)) { + is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x + is Outline.Rectangle -> 0f + is Outline.Generic -> minOf(size.width, size.height) / 2f + } + +/** + * A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot]. + * Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a + * popup, a screen with no host) it is drawn here, as a plain border. + */ +@Composable +internal fun Modifier.glideBorder( + hot: Boolean, + shape: Shape, + hotColor: Color, + restColor: Color = Color.Transparent, + hotWidth: Dp = 2.dp, + restWidth: Dp = 1.dp, +): Modifier { + val glide = LocalFocusGlide.current + if (glide == null || glide.view !== LocalView.current) { + return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape) + } + val src = remember { GlideSource() } + src.shape = shape + src.color = hotColor + src.width = hotWidth + DisposableEffect(glide, hot) { + if (hot) glide.claim(src) + onDispose { glide.release(src) } + } + val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this + return base.onGloballyPositioned { src.coords = it; glide.moved(src) } +} + +/** Leading edge on a focus move: quick and a little loose. */ +private fun lead(): AnimationSpec = Motion.sp(0.62f, 700f) +/** Trailing edge: slower, settles without a bounce to speak of. */ +private fun trail(): AnimationSpec = Motion.sp(0.78f, 360f) +/** Following a control that moves under the ring (a scroll): tight, no wobble. */ +private fun follow(): AnimationSpec = Motion.sp(1f, 2400f) + +/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */ +private const val SETTLE_MS = 450L + +/** + * Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the + * host's space, so put the host inside anything that moves as a whole (a sheet that slides in). + */ +@Composable +internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val view = LocalView.current + val density = LocalDensity.current + val dir = LocalLayoutDirection.current + val glide = remember(view) { FocusGlide(view) } + val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom + val corner = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val jobs = remember { arrayOfNulls(5) } + val cur = glide.current + var lastColor by remember { mutableStateOf(Color.Transparent) } + if (cur != null) lastColor = cur.color + val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor") + var width by remember { mutableStateOf(2.dp) } + if (cur != null) width = cur.width + + LaunchedEffect(glide) { + var shown: GlideSource? = null + var movedAt = 0L + var dirSpecs: List> = List(4) { follow() } + var delays = LongArray(4) + fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { + jobs[i]?.cancel() + jobs[i] = scope.launch { + if (wait > 0) delay(Motion.ms(wait.toInt()).toLong()) + edges[i].animateTo(v, spec) + } + } + fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) { + val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size + val r = cornerOf(s.shape, size, dir, density) + jobs[4]?.cancel() + jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) } + } + snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) -> + if (src == null) { + // Focus passing between two controls can let go of one a moment before the next + // takes it: wait a beat, so that reads as a move and not a fade out and back in. + delay(60) + scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + shown = null + return@collectLatest + } + var first = src !== shown + val start = System.nanoTime() + var last: Rect? = null + while (true) { + val b = glide.boundsOf(src) + if (b == null) { + if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + } else if (first) { + val prev = shown + val wasShowing = prev != null && alpha.targetValue > 0f + shown = src + first = false + if (!wasShowing) { + // Appearing: a hair bigger, pulled in onto the control as it fades up. + val out = with(density) { 3.dp.toPx() } + val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out) + jobs.forEach { it?.cancel() } + scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join() + dirSpecs = List(4) { Motion.sp(0.6f, 700f) } + delays = LongArray(4) + cornerTo(src, b, snap = true) + scope.launch { alpha.animateTo(1f, Motion.tw(140)) } + } else { + // Moving: the edges on the side it is heading lead. + val dx = b.center.x - (edges[0].value + edges[2].value) / 2f + val dy = b.center.y - (edges[1].value + edges[3].value) / 2f + val horizontal = abs(dx) >= abs(dy) + val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1) + val trailIdx = (leadIdx + 2) % 4 + dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } } + delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L } + cornerTo(src, b, snap = false) + scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + } + movedAt = System.nanoTime() + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) } + last = b + } else if (b != last) { + // It moved under the ring: keep the stretch while the move is fresh, else follow. + val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) } + if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + last = b + } + // Layer animations (a tile growing, a row sliding) move it without a relayout, so + // look again each frame for a moment after anything changes. + if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break + withFrameNanos { } + } + } + } + + Box( + modifier + .onGloballyPositioned { glide.host = it } + .drawWithContent { + drawContent() + val a = alpha.value + if (a <= 0.01f) return@drawWithContent + val w = width.toPx() + val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value + val sw = (r - l - w).coerceAtLeast(0f) + val sh = (b - t - w).coerceAtLeast(0f) + val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f) + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, + ) + }, + ) { + CompositionLocalProvider(LocalFocusGlide provides glide) { content() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt index 2d533fd52..1ec0fbfad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -1,22 +1,48 @@ package com.droiddeck.launcher.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless /** * The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup, - * a game's launch settings and the session drawer, so they say the same thing. + * a game's launch settings and the session drawer, so they say the same thing. Importing a + * Lossless.dll is offered once the account is known to own Lossless Scaling. */ @Composable internal fun FrameGenMenu( - host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean, - onPick: (engine: String, multiplier: Int) -> Unit, + host: MenuHost, mode: FrameGen.Mode, lossless: Lossless.State, + onPick: (FrameGen.Mode) -> Unit, + onImportLossless: () -> Unit, ) { - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam" - fun pick(e: String, m: Int) { onPick(e, m); host.open = null } - MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) } + val context = LocalContext.current + AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = stringResource(R.string.frame_gen_title)) { firstItemFocus -> + val need = when { + lossless.ready -> null + lossless.owned -> stringResource(R.string.lsfg_needs_install) + else -> stringResource(R.string.lsfg_needs_steam) + } + @Composable + fun item(m: FrameGen.Mode, enabled: Boolean = true, detail: String? = null, focus: FocusRequester? = null) = + MenuItem(FrameGen.label(context, m), checked = mode == m, enabled = enabled, detail = detail, focusRequester = focus) { + onPick(m); host.open = null + } + item(FrameGen.Mode.OFF, focus = firstItemFocus) + for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m)) + val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } + + FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) } + lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) } + if (lossless.owned) { + val using = when (lossless.source) { + Lossless.Source.STEAM -> stringResource(R.string.lsfg_using_steam) + Lossless.Source.IMPORT -> stringResource(R.string.lsfg_using_import) + Lossless.Source.NONE -> null + } + MenuItem(stringResource(R.string.lsfg_import), checked = false, detail = using) { host.open = null; onImportLossless() } + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index a49df3698..64e1bfc60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") val pal = LocalPalette.current - val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") Column( modifier = track .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } .clip(Shape12) .background(colors.surface) - .border(2.5.dp, ring, Shape12) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) .alpha(if (t.dim && !hot) 0.55f else 1f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 400c4b4b3..ccfe357ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 3c279aa87..ea67f9cd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") .clip(Shape12) .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) + .glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) .controllerConfirm(onClick = onLaunch) .padding(horizontal = 10.dp, vertical = 6.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index e1cd31996..18630a028 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -201,7 +201,6 @@ private fun RailItem( if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) - val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") Box( contentAlignment = Alignment.Center, @@ -211,7 +210,7 @@ private fun RailItem( .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) .background(fill) - .border(2.dp, ring, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), @@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on .width(if (iconOnly) 52.dp else 80.dp) .clip(Shape14) .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) - .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) .semantics { contentDescription = "Resume $name" } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1c0619fa3..74f5caaf3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -91,9 +92,8 @@ class FrontEndState( val steamGames: List, val emulators: List, val running: String?, - val frameGenEngine: String = FrameGen.ENGINE_OFF, - val frameGenMultiplier: Int = 2, - val lsfgReady: Boolean = false, + val frameGen: FrameGen.Mode = FrameGen.Mode.OFF, + val lossless: Lossless.State = Lossless.State.NONE, val pageKey: String? = null, val theme: String = Themes.GRAPHITE, val isHomeApp: Boolean = false, @@ -139,7 +139,8 @@ class FrontEndActions( val onInstallPackage: (String) -> Unit, val onRemovePackage: (String) -> Unit, val onRuntime: () -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index b8cee728e..697e04b3c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInputModeManager @@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.R // The Setup page: runtime and device checks, tools, frame generation and launch settings. @@ -279,7 +281,7 @@ internal fun SetupPanel( true to SessionPrefs.BACK_QAM_THEN_MENU, ), s.backActionsInverted, onPick = a.onBackActionsInverted, ) - SettingsRow("Frame generation", "Select the frame generation mode") { + SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) { Box { ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) @@ -380,7 +382,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) .controllerConfirm(onClick = t.onClick) .padding(horizontal = 14.dp, vertical = 12.dp), @@ -393,7 +395,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { @Composable private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless) } /** @@ -409,7 +411,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } } add { m -> Box(m) { - SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { + SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) @@ -435,14 +437,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi val hot = rememberHot(src) val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") Column( verticalArrangement = Arrangement.spacedBy(3.dp), modifier = modifier.paneItem(id) .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, edge, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..8f1f8b3e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp) .clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) + .glideBorder(hot, shape, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 8.dp, end = 16.dp), @@ -180,7 +180,7 @@ internal fun PrimaryButton( .shine(hot, 0.45f) // The grow and shine alone barely show on the light fill: outline it when a // controller is on it, as the other controls are. - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() @@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") Box( contentAlignment = Alignment.Center, - modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) val hot = rememberHot(src) val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) + modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 6f228a0ca..d9f26e6b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -51,6 +51,8 @@ class ModeSettings( val hdrReason: String?, /** The GPU drivers in use, as the row that opens them on the Components page says it. */ val gpuDrivers: String = "Auto", + /** Frames per second the session is capped at; 0 = none. */ + val fpsLimit: Int = 0, val touchMode: String, val suspendPolicy: String, /** Steam only. */ @@ -102,6 +104,7 @@ class ModeSettingsActions( val onHdr: (Boolean) -> Unit, /** Opens the GPU drivers on the Components page: they are shared by every session. */ val onGpuDrivers: () -> Unit = {}, + val onFpsLimit: (Int) -> Unit = {}, val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onOsc: (String) -> Unit, @@ -168,6 +171,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.", com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape, ) + ChoiceRow( + host, "fps", "Frame limit", "Applies next session.", + com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit, + note = "Caps the whole session. The screen switches to a rate the cap divides evenly.", + onPick = a.onFpsLimit, + ) if (editCustom) CustomResolutionDialog( initial = custom, onSave = { size -> editCustom = false; a.onCustomResolution(size) }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index ad3b34405..3f7a03558 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -202,12 +202,11 @@ private fun RowScope.KeyCap( else -> Color.White.copy(alpha = 0.07f) }, label = "keyFill", ) - val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f) Box( contentAlignment = Alignment.Center, modifier = modifier.weight(weight).fillMaxHeight() .background(fill, RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume() @@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxHeight() .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f)) .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .padding(horizontal = 12.dp), ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 1083babe2..fb441ef06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -38,6 +38,7 @@ fun PerformancePage( noGlError: Boolean, noXalia: Boolean, gamescopeRealtime: Boolean, + gpuClockPin: Boolean, prootNoSeccomp: Boolean, guestHostname: String, phantomWarning: String?, @@ -48,6 +49,7 @@ fun PerformancePage( onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, onGamescopeRealtime: (Boolean) -> Unit, + onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, @@ -102,6 +104,13 @@ fun PerformancePage( gamescopeRealtime, onChange = onGamescopeRealtime, ) } + SettingsGroup("GPU") { + ToggleRow( + host, "gpuclock", "Hold the GPU at its top clock", + "Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.", + gpuClockPin, onChange = onGpuClockPin, + ) + } SettingsGroup("Session fixes") { ToggleRow( host, "sysmem", "Turnip: sysmem rendering", diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index f02b05134..af38940f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,6 +1,28 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.draw.alpha +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -93,9 +113,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalContext import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FexPreset import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.input.SecondScreenDisplay @@ -137,9 +159,8 @@ class DrawerActions( val isHomeApp: Boolean, val androidApps: List, val hudOn: Boolean, - val frameGenEngine: String, - val frameGenMultiplier: Int, - val lsfgReady: Boolean, + val frameGen: FrameGen.Mode, + val lossless: Lossless.State, val oscMode: String, val suspendPolicy: String, val backActionsInverted: Boolean, @@ -153,7 +174,8 @@ class DrawerActions( val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, /** The Android keyboard (text, turned into key presses). */ val onKeyboard: () -> Unit, /** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */ @@ -195,6 +217,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } + var sheetCoords by remember { mutableStateOf(null) } + var stopCoords by remember { mutableStateOf(null) } val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } @@ -240,10 +264,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { + FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier - .fillMaxHeight() - .width(sheetWidth) + .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .focusGroup() @@ -255,7 +279,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) { Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true } + StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true } } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -323,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen, ) val fgOpen = host.open == "fg" - val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier) - SettingsRow("Frame generation", null, highlighted = fgOpen) { + val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) + SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { Box { ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" } - FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless) } } } @@ -437,6 +461,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } } + StopConfirm( + open = confirmStop, + pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } }, + controllerActive = controllerActive, + onCancel = { confirmStop = false }, + onStop = { onScreen -> + confirmStop = false + val from = a.onStopFrom + if (from != null) from(onScreen) else a.onStop() + }, + ) + } } } } @@ -457,44 +493,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onDismiss = { appToChooseDisplay = null }, ) } - if (confirmStop) { - val cancelFocus = remember { FocusRequester() } - val cancel = { confirmStop = false } - // Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken - // against the screen and the session's overlay converts them back. - var stopOnScreen by remember { mutableStateOf(null) } - val stop = { - confirmStop = false - val from = a.onStopFrom - if (from != null) from(stopOnScreen) else a.onStop() - } - LaunchedEffect(controllerActive) { - if (controllerActive) { - androidx.compose.runtime.withFrameNanos { } - runCatching { cancelFocus.requestFocus() } - } - } - AlertDialog( - onDismissRequest = cancel, - modifier = Modifier.controllerBack(onBack = cancel), - title = { Text("Stop session?") }, - confirmButton = { - val dialogView = LocalView.current - TextButton( - onClick = stop, - modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c -> - val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) } - stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat()) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colors.error), - ) { Text("Stop") } - }, - dismissButton = { - TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus) - .controllerConfirm(onClick = cancel)) { Text("Cancel") } - }, - ) - } } @Composable @@ -524,7 +522,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" } - .clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape) + .clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 12.dp, end = 14.dp), @@ -549,7 +547,7 @@ private fun QuickAction( val shape = RoundedCornerShape(12.dp) val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) .clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onConfirm ?: onClick) @@ -580,7 +578,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier Box( contentAlignment = Alignment.Center, modifier = modifier.size(44.dp).clip(ring) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ring) + .glideBorder(hot, ring, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .controllerConfirm(onClick = onClick) .semantics { contentDescription = description }, @@ -639,7 +637,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), ), ) - .border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp)) + .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) .semantics { contentDescription = "${drawerPageTitles[index]} page" } .hoverable(source) .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) @@ -788,3 +786,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f private const val DRAWER_TAB_SIDE_SCALE = 0.5f private const val DRAWER_TAB_SIDE_ALPHA = 0.7f + +/** Room between the Stop pill and the box that steps out under it: the pull. */ +private val StepPull = 6.dp +private val StepBoxCorner = 16.dp +private val StepFillet = 10.dp +private val StepBoxMaxWidth = 264.dp + +/** + * The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge + * and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and + * the inside corner where the pull meets the box is rounded the other way. While the box is still + * no wider than the pill it is just the pill, stretched down. + */ +private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) { + path.reset() + val r = p.right; val tl = p.left; val tt = p.top + val pb = maxOf(bottom, p.bottom) + val l = minOf(left, tl) + val pt = minOf(pullTop, pb) + val sw = tl - l + val h = (pb - pt).coerceAtLeast(0f) + val rt = minOf(p.height / 2f, p.width / 2f) + val rbr = minOf(box, (pb - tt) / 2f) + val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f) + val rpl = minOf(box, sw / 2f, h / 2f) + val f = minOf(fillet, sw / 2f) + val yl = minOf(pt + rpl, pb - rbl) + fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) { + if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false) + } + path.moveTo(tl + rt, tt) + path.lineTo(r - rt, tt) + corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt) + path.lineTo(r, pb - rbr) + corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb) + path.lineTo(l + rbl, pb) + corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl) + path.lineTo(l, yl) + corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt) + path.lineTo(tl - f, pt) + corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f) + path.lineTo(tl, tt + rt) + corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt) + path.close() +} + +/** + * Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width + * drops out of it and a wider box steps out to the left under it for the choices, right edges + * flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill, + * width first. [onStop] gets Stop's bounds on screen for the flood. + */ +@Composable +private fun StopConfirm( + open: Boolean, + pill: Rect?, + controllerActive: Boolean, + onCancel: () -> Unit, + onStop: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val left = remember { Animatable(0f) } + val bottom = remember { Animatable(0f) } + val tint = remember { Animatable(0f) } + val dim = remember { Animatable(0f) } + val items = remember { List(3) { Animatable(0f) } } + // Composed from the first open until it has folded back into the pill. + var shown by remember { mutableStateOf(false) } + var grown by remember { mutableStateOf(false) } + var content by remember { mutableStateOf(null) } + val cancelFocus = remember { FocusRequester() } + var stopFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + var stopCoords by remember { mutableStateOf(null) } + val view = LocalView.current + if (!open && !shown) return + val pull = with(density) { StepPull.toPx() } + + LaunchedEffect(open, pill, content) { + val p = pill ?: return@LaunchedEffect + val c = content ?: return@LaunchedEffect + val boxLeft = (p.right - c.width).coerceAtLeast(0f) + val boxBottom = p.bottom + pull + c.height + if (open) { + shown = true + if (!grown) { + left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f) + items.forEach { it.snapTo(0f) } + grown = true + } + coroutineScope { + launch { dim.animateTo(1f, Motion.tw(260)) } + launch { tint.animateTo(1f, Motion.tw(200)) } + launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) } + launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) } + items.forEachIndexed { i, a -> + launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) } + } + if (controllerActive) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { cancelFocus.requestFocus() } + } + } + } else if (grown) { + coroutineScope { + items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) } + } + grown = false + shown = false + content = null + } + } + val p = pill ?: return + + Box(Modifier.fillMaxSize()) { + // The rest of the sheet dims behind it, and a tap there cancels. + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel), + ) + val path = remember { Path() } + val boxCorner = with(density) { StepBoxCorner.toPx() } + val fillet = with(density) { StepFillet.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val restFill = colors.surface + val hotFill = lerp(colors.surface, colors.error, 0.22f) + Canvas(Modifier.fillMaxSize()) { + if (!grown) return@Canvas + stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet) + drawPath(path, lerp(hotFill, restFill, tint.value)) + drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim)) + } + // The pill's label rides on top, as the pull's handle. + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) } + .size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() }) + .padding(start = 12.dp, end = 14.dp), + ) { + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) + Column( + modifier = Modifier + .offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) } + .width(with(density) { maxW.toDp() }) + .onSizeChanged { if (content != it) content = it } + .controllerBack(onCancel) + .onPreviewKeyEvent { e -> + // Keep a controller in here, as the dialog's own window used to. + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp, Key.DirectionDown -> true + Key.DirectionLeft -> cancelFocused + Key.DirectionRight -> stopFocused + else -> false + } + } + .padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp), + ) { + Text( + "Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) { + ConfirmChoice( + "Cancel", danger = false, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() } + .focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }, + onClick = onCancel, + ) + ConfirmChoice( + "Stop", danger = true, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() } + .onFocusChanged { stopFocused = it.isFocused } + .onGloballyPositioned { stopCoords = it }, + ) { + val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) } + onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat())) + } + } + } + } +} + +/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */ +@Composable +private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 40.dp).clip(shape) + .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) + .glideBorder(hot, shape, accent) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 1b36b1c4b..b332958d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -148,7 +148,7 @@ internal fun TabStrip( .then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier) .clip(tabShape) .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape) + .glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) .padding(horizontal = 16.dp, vertical = 12.dp), @@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val pal = LocalPalette.current - val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge") val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo .heightIn(min = 44.dp) .clip(RoundedCornerShape(10.dp)) .background(colors.surfaceVariant) - .border(1.dp, edge, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack") - val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge") val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob") val flip = { onChange(!checked) } val shape = RoundedCornerShape(99.dp) @@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul ) { Box( Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track) - .border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape), + .glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2), ) { Box( Modifier.padding(4.dp).size(22.dp) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index da057cb72..5c6002f68 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, modifier = Modifier.paneItem("pill:$label").clip(shape) .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 9.dp), @@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}") .clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14) + .glideBorder(hot, Shape14, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 936ce1555..6d824a604 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -213,7 +213,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) { Text( if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more") - .clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .clip(Shape12).glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) .controllerConfirm(onClick = toggle) .padding(horizontal = 6.dp, vertical = 4.dp), @@ -273,13 +273,12 @@ private fun ChannelCard( val src = remember { MutableInteractionSource() } val hot = rememberHot(src) // Focus shows at once, as on the Components page: no ripple, no fade. - val edge = if (hot || selected) pal.signal else pal.line val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().paneItem("channel:$title") - .clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14) + .clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line) .alpha(if (enabled) 1f else 0.55f) .hoverable(src) .clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick) @@ -316,7 +315,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) { .heightIn(min = 52.dp) .clip(Shape12) .background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12) + .glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line) .hoverable(src) .clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) .controllerConfirm(onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 52cde55e8..a067ef020 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -381,7 +381,7 @@ internal fun AdbTextField( .fillMaxWidth() .height(inputHeight) .background(colors.surfaceVariant, shape) - .border(1.dp, if (focused) palette.signal else palette.line2, shape) + .glideBorder(focused, shape, palette.signal, palette.line2) .padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart, ) { diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 973332542..a19bd858a 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -32,7 +32,9 @@ object CompositorHost { outputWidth: Int, outputHeight: Int, refreshHz: Float, + fpsLimit: Int, ): Boolean { + pace(refreshHz, fpsLimit) if (started) { attached = surface WaylandCompositor.nativeSetSurface(surface) @@ -41,10 +43,6 @@ object CompositorHost { } WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight) WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) - // The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate - // already being achieved, and on a phone whose vendor picks clocks from that a session - // that has been slowed reads as one that wants to be. (WinNative, WaylandSession.) - WaylandCompositor.nativeSetLayerFrameRate(refreshHz) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) @@ -54,6 +52,19 @@ object CompositorHost { return true } + /** + * The session's frame cap, on every attach: the compositor outlives sessions, and the next one + * may have a different cap. The release pacer hands a game one buffer back per capped frame + * (per refresh without a cap), and the zero-copy layer votes for the same cadence. Left at 0 + * the vote is inferred from the rate already being achieved, and on a phone whose vendor picks + * clocks from that a session that has been slowed reads as one that wants to be. (WinNative, + * WaylandSession.) + */ + private fun pace(refreshHz: Float, fpsLimit: Int) { + WaylandCompositor.nativeSetFpsLimit(fpsLimit) + WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz) + } + /** * The Surface changed size under the compositor (a foldable opening or closing). Android does * not always recreate the Surface for that, and a swapchain built for the old size keeps @@ -69,7 +80,7 @@ object CompositorHost { // came back stretched with an engine on and right with it off. Disarmed, the window is // rebound and the swapchain rebuilt; the caller re-arms once that has settled, and every // engine-side object is made again for the new size. - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "surface resized: rebinding the window, frame generation re-armed after") WaylandCompositor.nativeSetSurface(null) attached = surface @@ -85,7 +96,7 @@ object CompositorHost { */ fun rearmFrameGen(rearm: () -> Unit) { if (!started) return - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "presenting window changed: frame generation re-armed") android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(rearm, 600) } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 048ff2ef1..71c7172a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -207,6 +207,9 @@ public final class WaylandCompositor { */ public static native int nativeCursorSnapshot(int[] out); + /** The serial {@link #nativeCursorSnapshot} would report, without copying the image. */ + public static native int nativeCursorSerial(); + /** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */ public static final int CURSOR_BUF_INTS = 6 + 256 * 256; @@ -434,6 +437,14 @@ public final class WaylandCompositor { * back to the game, like the X11 IdleNotify pacer, so the game itself slows to the cap. */ public static native void nativeSetFpsLimit(int fps); + /** KGSL power control off (true: the GPU held at its top clock) or back on. Device-wide and + * outlives the process; see {@code GpuClockPin}. A no-op without /dev/kgsl-3d0. */ + public static native void nativeSetGpuTurbo(boolean on); + + /** setpriority() on one of our own processes or threads, only ever raising it (no cgroup move, + * unlike android.os.Process.setThreadPriority). Returns the nice it is left at; 100 = unknown. */ + public static native int nativeRaisePriority(int tid, int nice); + /** Inject the app's X-server input in scene (virtual desktop) pixels. type 2 = move to a,b; * 3 = evdev button a (BTN_LEFT=0x110…) pressed (b=1) or released (b=0); 4 = a wheel steps, * negative = up. */ @@ -450,10 +461,12 @@ public final class WaylandCompositor { public static native void nativeSetFrameGenEngine(int kind); /** Arm (multiplier 2..4: one real frame plus multiplier-1 interpolated ones per game frame) or - * disarm. Generated frames are presented ahead of the real frame on consecutive vblanks. */ - public static native void nativeSetFrameGenArmed(boolean armed, int multiplier); + * disarm. A targetFps above 0 makes LSFG adaptive instead: it generates 0..3 frames per game + * frame, whatever reaches that rate. Generated frames are presented ahead of the real frame + * on consecutive vblanks. */ + public static native void nativeSetFrameGenArmed(boolean armed, int multiplier, int targetFps); - /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code LsfgNative.cacheFile}). */ + /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code Lossless.cacheFile}). */ public static native void nativeSetLsfgCachePath(String path); /** Flow scale (0.25-1.0) and the panel's real refresh rate (the pacer never generates above it). */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10cf334af..cfa4435bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,28 @@ Resume Stop session Frame generation + Select the frame generation mode + Off + Win-FG %1$d× + LSFG %1$d× + LSFG Adaptive %1$d + Needs Lossless Scaling on Steam + Install Lossless Scaling in Steam or import Lossless.dll + Import Lossless.dll… + Using the copy from Steam + Using an imported copy + Choose Lossless.dll + Lossless.dll imported + This Lossless.dll is already in use + Lossless Scaling is not owned on this Steam account + This file is not Lossless Scaling\'s Lossless.dll + Could not prepare LSFG from this Lossless.dll + LSFG needs Lossless Scaling from Steam or an imported Lossless.dll + LSFG could not prepare its shaders from Lossless.dll + %1$s: unsupported + %1$s: engine failed + %1$s: needs Lossless Scaling + %1$s %2$s · starting Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games. Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts. Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk. diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt new file mode 100644 index 000000000..fe3c9773d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class FrameGenTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun offUntilPicked() { + assertEquals(FrameGen.Mode.OFF, FrameGen.mode(context)) + } + + @Test fun adaptiveTargetIsKept() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90), FrameGen.mode(context)) + } + + @Test fun fixedMultiplierReplacesATarget() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 120)) + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, 3)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, 3), FrameGen.mode(context)) + } + + @Test fun onlyLsfgIsAdaptive() { + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() + .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt new file mode 100644 index 000000000..158351477 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LosslessTest { + private lateinit var context: Context + private lateinit var flag: File + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("lossless", Context.MODE_PRIVATE).edit().clear().commit() + flag = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + flag.delete() + } + + @Test fun nothingIsOwnedOrReadyUntilTheSessionSaysSo() { + assertEquals(Lossless.State.NONE, Lossless.state(context)) + assertNull(Lossless.cacheFile(context)) + } + + @Test fun theSessionsOwnershipFlagIsRememberedForGood() { + flag.parentFile!!.mkdirs() + flag.writeText("") + assertTrue(Lossless.owned(context)) + flag.delete() + assertTrue(Lossless.owned(context)) + } + + @Test fun importIsRefusedWithoutOwnership() { + val dll = File(context.cacheDir, "Lossless.dll").apply { writeText("not a dll") } + assertFalse(Lossless.owned(context)) + assertEquals(Lossless.ImportResult.NOT_OWNED, Lossless.import(context, dll)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists index 9d2d60fa7..61c8d35a6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists @@ -1,5 +1,6 @@ #!/bin/bash -# Marks Steam's shared redistributables as already run in every Proton prefix. +# Marks Steam's shared redistributables as already run in every Proton prefix, and in the +# template Proton makes new prefixes from. # # A game whose depot lists _CommonRedist makes the client run the real installer inside the # prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves @@ -14,27 +15,15 @@ set -u steam_root=${1:?steam root} redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" -[ -d "$redist" ] || exit 0 -# Every process an install script would run becomes the registry value the client writes once it -# has run it. Names sit one level inside "Run Process", the key they share a level further in. -block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk -v now="$(date +%s)" ' - function emit(key, name, path) { - path = key - sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) - if (path == key) - return - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; - # both spellings are seeded because the reader picks by its own word size. - sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - } +# Every process an install script would run, as the registry key and value the client writes once +# it has run it. Names sit one level inside "Run Process", the key they share a level further in. +pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk ' FNR == 1 { depth = 0; name = ""; key = "" } /^[[:space:]]*\{/ { depth++; next } /^[[:space:]]*\}/ { if (depth == 3 && name != "" && key != "") - emit(key, name) + printf "%s\t%s\n", key, name depth-- next } @@ -44,15 +33,62 @@ block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | x if (n >= 3 && depth == 2) { name = part[2]; key = "" } } ') -[ -n "$block" ] || exit 0 -stamp=$(printf '%s' "$block" | cksum) +# The client downloads the scripts with the first game that wants them, which is too late for that +# game; until then the set they held when this was written stands in. (WinNative's list.) +[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN' +DirectX\\Jun2010 dxsetup +.NET\\4.8 4.8 +vcredist\\2008 x86 SP1 +vcredist\\2008 x64 SP1 +vcredist\\2010 x86 +vcredist\\2010 x64 +vcredist\\2013 x86 12.0.30501 +vcredist\\2013 x64 12.0.30501 +vcredist\\2015 x86 Update 3 14.0.24215.0 +vcredist\\2015 x64 Update 3 14.0.24215.0 +vcredist\\2017 x86 14.10.25008.0 +vcredist\\2017 x64 14.10.25008.0 +vcredist\\2019 x86 14.28.29334.0 +vcredist\\2019 x64 14.28.29334.0 +vcredist\\2022 x86 14.51.36247.0 +vcredist\\2022 x64 14.51.36247.0 +KNOWN +) +# The stamp is the set of names, taken before the block below dates its keys: stamped from the +# dated block it never matched, and every prefix had the whole block appended again on every beat. +stamp=$(printf '%s' "$pairs" | cksum) -for reg in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ +block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" ' + { + path = $1 + sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) + if (path == $1) + next + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; + # both spellings are seeded because the reader picks by its own word size. + sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + } +') +[ -n "$block" ] || exit 0 + +# Proton builds a new prefix from its template, and the client runs the install scripts in that +# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a +# changed one as a new template and upgrades every prefix made from the old. +for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ + "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \ + "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ /mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue seeded=$(dirname "$reg")/.bannerlator-redists [ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue - printf '%s\n' "$block" >>"$reg" || continue - printf '%s\n' "$stamp" >"$seeded" - echo "== seeded redist markers into $reg" + dated=$(mktemp) || continue + touch -r "$reg" "$dated" + if printf '%s\n' "$block" >>"$reg"; then + printf '%s\n' "$stamp" >"$seeded" + echo "== seeded redist markers into $reg" + fi + touch -r "$dated" "$reg" + rm -f "$dated" done diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index d579dc73b..f3b40af88 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -273,13 +273,24 @@ case $mode in # device it took 5 of 8, leaving out both little cores and the fastest one - which is a # reasonable thing to do while a game is running and the wrong thing when the client is all # there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns - # children continuously and each one inherits Steam's choice, not ours. + # children continuously and each one inherits Steam's choice, not ours. The beat reads /proc + # itself instead of running pgrep, and only runs taskset for a process that is new or has + # grown threads since it was last pinned: every fork is a round of traced syscalls, and a + # beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because + # without it only a process's main thread moves and the renderer's workers stay where they were. if [ -n "${BL_CLIENT_CPUS:-}" ]; then step "client cores: $BL_CLIENT_CPUS" ( - for _ in $(seq 1 720); do - for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do - taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + declare -A _bl_pinned=() + for ((_i = 0; _i < 720; _i++)); do + for _d in /proc/[0-9]*; do + IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue + case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac + _t=("$_d"/task/*) + _p=${_d#/proc/} + [ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue + taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + _bl_pinned[$_p]=${#_t[@]} done nap 5 done @@ -420,13 +431,33 @@ case $mode in prev=$app done ) & + # The library beat. Both helpers are whole programs under proot (the compat one a python + # start and a parse of the client's config), and nearly every beat has nothing new for them. + # The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born + # marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it + # reads from has changed since its last run - a new manifest, a new depot or tool, the client + # rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute + # regardless. ( beat=0 - for _ in $(seq 1 720); do + compat_stamp=$HOME/.bl-compat-beat + rm -f "$compat_stamp" + compat_due() { + local d + [ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0 + for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \ + "$steam_root/compatibilitytools.d" /mnt/bannerlator-sd/steamapps; do + [ "$d" -nt "$compat_stamp" ] && return 0 + done + return 1 + } + for ((_i = 0; _i < 720; _i++)); do nap 5 - bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true beat=$((beat + 1)) - if [ "$((beat % 3))" -eq 0 ]; then + [ "$((beat % 3))" -eq 0 ] || continue + bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true + if compat_due; then + : > "$compat_stamp" bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true fi done @@ -578,6 +609,24 @@ BUSCONF bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & games_watcher=$! fi + # Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in + # client once a session until the answer is yes. From then on the app offers importing + # Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again. + if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then + ( + owned=$HOME/.config/droiddeck/lossless-owned + for ((_i = 0; _i < 60; _i++)); do + nap 30 + [ -e "$owned" ] && exit 0 + answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue + case $answer in + *'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;; + *) echo "== Lossless Scaling: not owned by this account" ;; + esac + exit 0 + done + ) & + fi echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 28bb1f89d..fb70b40e2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -152,6 +152,9 @@ bl_game_cpus() { # container that path never reaches Proton either. (WinNative's winnative-proton-launch.) BL_LIBPATH_SETUP = """ bl_libpath() { + # A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread + # here, where only PulseAudio answers. (WinNative, winnative-proton-launch.) + export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0} [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 local kept= dir old_ifs=$IFS IFS=: @@ -246,17 +249,29 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +# The newest of the ARM64 depots the client has, by the build number that opens each one's version +# file: taking the first one found ran a stale Experimental over a newer 11.0. (WinNative.) +depot= +best=0 for name in %s; do - depot="$steam_root/steamapps/common/$name" - if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then - bl_components "$depot" - bl_directaudio "$depot" - bl_game_cpus - bl_idtech3 "$@" - bl_libpath - exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" + candidate="$steam_root/steamapps/common/$name" + [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue + build=0 + read -r build _ 2>/dev/null < "$candidate/version" + case $build in "" | *[!0-9]*) build=0 ;; esac + if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then + depot=$candidate + best=$build fi done +if [ -n "$depot" ]; then + bl_components "$depot" + bl_directaudio "$depot" + bl_game_cpus + bl_idtech3 "$@" + bl_libpath + exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" +fi echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 exit 1 """ % " ".join('"%s"' % n for n in SOURCES) @@ -568,15 +583,23 @@ def register_default(config_path, apps=(), name=None, protect=()): write_changed(config_path, text) +def depot_build(depot): + """The build number a depot's version file starts with; 0 when there is none.""" + try: + with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f: + first = f.read().split(None, 1) + except OSError: + return 0 + return int(first[0]) if first and first[0].isdigit() else 0 + + def find_source(steam_root): - """The ARM64 Proton depot the client has downloaded, or None.""" + """The newest ARM64 Proton depot the client has downloaded (the one the launcher runs), or None.""" common = os.path.join(steam_root, "steamapps", "common") - for name in SOURCES: - depot = os.path.join(common, name) - # bin-arm64 is what makes it the native build rather than the x86_64 one. - if os.path.isdir(os.path.join(depot, "files", "bin-arm64")): - return depot - return None + # bin-arm64 is what makes it the native build rather than the x86_64 one. + found = [os.path.join(common, name) for name in SOURCES + if os.path.isdir(os.path.join(common, name, "files", "bin-arm64"))] + return max(found, key=depot_build) if found else None def build_tool(tool_dir): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 3ab3e9844..1c2add014 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -128,6 +128,22 @@ def probe(steam_root, ids): method(client, 1, C.c_bool, C.c_int)(client, pipe) +def game_running(): + """Whether a Proton game is up: a wineserver is there for as long as any prefix is in use.""" + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open("/proc/%s/comm" % pid) as f: + if f.read().strip() == "wineserver": + return True + except OSError: + pass + return False + + def watch(steam_root, listing): games = read_json(listing, []) if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: @@ -138,7 +154,10 @@ def watch(steam_root, listing): # The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership # only moves with a purchase or a new match, so it runs when the matches or their sources # change, after a failed probe, and otherwise every five minutes - not on every 30 s beat. - probed, next_probe = None, 0 + # A failed probe is retried on a doubling delay up to those five minutes rather than on every + # beat (a client that is not logged on fails it for as long as it stays that way), and none + # starts while a game is running: the answer can wait, the game's cores cannot. + probed, next_probe, failures = None, 0, 0 while True: now = time.monotonic() for game in games: @@ -155,7 +174,9 @@ def watch(steam_root, listing): next_lookup = now + 300 status = "waiting for a matching Steam title" key = (dict(candidates), dict(sources)) - if candidates and (key != probed or now >= next_probe): + if candidates and (key != probed or now >= next_probe) and game_running(): + status = last_status + elif candidates and (key != probed or now >= next_probe): probed, next_probe = key, now + 300 try: result = subprocess.run([sys.executable, __file__, "probe", steam_root, @@ -163,8 +184,10 @@ def watch(steam_root, listing): capture_output=True, text=True, timeout=15) if result.returncode != 0: status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) - probed = None + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) else: + failures = 0 line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0: @@ -175,7 +198,8 @@ def watch(steam_root, listing): status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values())) except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: status = "ownership check unavailable: " + str(error) - probed = None + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) elif candidates: status = last_status if status != last_status: diff --git a/tools/linuxfs/preload/kgslpoll.c b/tools/linuxfs/preload/kgslpoll.c new file mode 100644 index 000000000..00eb8f01f --- /dev/null +++ b/tools/linuxfs/preload/kgslpoll.c @@ -0,0 +1,79 @@ +/* + * A zero-timeout KGSL timestamp wait answered as the poll it was meant to be. + * + * Turnip's KGSL backend emulates timeline semaphores with timestamps, and asks "has this one + * retired yet?" by waiting on it with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0. + * KGSL reads a timeout of 0 as no timeout at all, so the poll blocks until the GPU gets there. + * Mesa's timeline bookkeeping polls like that under a lock on every signalling submit, which put + * vkd3d-proton, DXVK, gamescope and Zink in lock-step with the GPU: one frame queued, never two. + * Found and measured by Bannerlator (PROGRESS_LOG 2026-09-29: a D3D12 demo went from 614 to + * 4335 fps with the same answer given in its Vulkan adapter); the driver-side fix is on + * Banners-Turnip, and this covers the drivers that do not carry it yet. + * + * The answer comes from the context's retired timestamp, compared the way KGSL compares them + * (with wrap-around). Anything the read does not answer - an unknown context, a device that is not + * KGSL - goes to the kernel as before. BL_NO_KGSL_POLL_FIX=1 turns it off. + */ +#define _GNU_SOURCE +#include +#include +#include +#include + +#define KGSL_IOC_TYPE 0x09 +#define KGSL_TIMESTAMP_RETIRED 0x00000002 + +struct kgsl_device_waittimestamp_ctxtid { + unsigned int context_id; + unsigned int timestamp; + unsigned int timeout; +}; + +struct kgsl_cmdstream_readtimestamp_ctxtid { + unsigned int context_id; + unsigned int type; + unsigned int timestamp; +}; + +#define IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID \ + _IOW(KGSL_IOC_TYPE, 0x7, struct kgsl_device_waittimestamp_ctxtid) +#define IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID \ + _IOWR(KGSL_IOC_TYPE, 0x16, struct kgsl_cmdstream_readtimestamp_ctxtid) + +static int enabled(void) { + static int cached = -1; + const char *off; + + if (cached >= 0) return cached; + off = getenv("BL_NO_KGSL_POLL_FIX"); + cached = !(off != NULL && off[0] == '1'); + return cached; +} + +/* True when the call was answered here, with its result in *rc (and errno set on failure). */ +__attribute__((visibility("hidden"))) int bl_kgsl_poll(int (*real)(int, unsigned long, void *), + int fd, unsigned long request, void *arg, + int *rc) { + const struct kgsl_device_waittimestamp_ctxtid *wait = arg; + struct kgsl_cmdstream_readtimestamp_ctxtid read; + int saved = errno; + + if (request != IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID || arg == NULL || wait->timeout != 0 || + !enabled()) + return 0; + read.context_id = wait->context_id; + read.type = KGSL_TIMESTAMP_RETIRED; + read.timestamp = 0; + if (real(fd, IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID, &read) != 0) { + errno = saved; + return 0; + } + if ((int32_t)(read.timestamp - wait->timestamp) >= 0) { + errno = saved; + *rc = 0; + } else { + errno = ETIMEDOUT; + *rc = -1; + } + return 1; +} diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 4b79c3f3f..a835c6cdd 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -349,6 +349,9 @@ static int is_link_query(unsigned long request) { || request == SIOCGIFBRDADDR; } +int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, + void *arg, int *rc) __attribute__((visibility("hidden"))); + /* Every DRM and evdev call comes through here, so the lookup is done once. Two threads racing * to store it store the same pointer. */ int ioctl(int fd, unsigned long request, ...) { @@ -360,7 +363,9 @@ int ioctl(int fd, unsigned long request, ...) { va_end(ap); if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); - int rc = real(fd, request, arg); + int rc; + if (bl_kgsl_poll(real, fd, request, arg, &rc)) return rc; + rc = real(fd, request, arg); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; if (answer_ifreq(request, (struct ifreq *) arg, rc)) return 0;