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/build.gradle b/app/build.gradle index 37efe56dc..d2bb5daaf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO ?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: '' def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' } def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true' +// The release key's certificate: a build signed with it, from CI or a computer, updates in place. +def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim() // When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds. def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' } // tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as. @@ -83,6 +85,7 @@ android { buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" buildConfigField "boolean", "CI_BUILD", "${ciBuild}" + buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\"" buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L" buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\"" 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 19e13cc88..77551f925 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() { @@ -14,6 +16,9 @@ class App : Application() { CrashHandler.install(this) // 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) + com.droiddeck.launcher.update.AppUpdates.init(this) + GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt index 3cbb18656..87c3f2790 100644 --- a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt @@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat var compCatalog by mutableStateOf>(emptyList()) var compCatalogAt by mutableStateOf(0L) var compProton by mutableStateOf(null) - var compComp by mutableStateOf("dxvk") + var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB) var compChecking by mutableStateOf(false) var compBusy by mutableStateOf(null) var compDownloads by mutableStateOf>(emptyMap()) diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 88c2e8853..ff5ce18fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -10,6 +10,10 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableIntStateOf +import com.droiddeck.launcher.gpu.DriverBundle +import com.droiddeck.launcher.gpu.DriverPairs +import com.droiddeck.launcher.gpu.GpuInfo import com.droiddeck.launcher.gpu.LinuxVulkanDriver import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager import com.droiddeck.launcher.gpu.TurnipDriver @@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.DriverRow +import com.droiddeck.launcher.ui.GpuDriversState +import com.droiddeck.launcher.ui.PairRow +import com.droiddeck.launcher.wayland.CompositorHost /** - * The Linux and Android driver menus: the drivers each can pick from, the release downloads the - * last check found, and what each mode is set to. The launcher screen keeps one and hands its - * rows to the mode settings dialog. + * The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and + * - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the + * Linux (runtime) and Android (display) lists as before, each picked on its own - except a + * bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher + * screen keeps one and hands it to the Components page's GPU drivers tab. */ internal class DriverMenus(private val activity: Activity, private val ui: Handler) { var linuxRows by mutableStateOf>(emptyList()) @@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl var canRestoreBundled by mutableStateOf(false) /** Asset name -> download percent, while it downloads. */ private val releaseProgress = HashMap() - var linuxSteam by mutableStateOf("") - var linuxDesktop by mutableStateOf("") + var linuxSelected by mutableStateOf("") + val gpu: GpuInfo = GpuInfo.detect() + // Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made. + var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO) + var pairRows by mutableStateOf>(emptyList()) + /** The pair being installed, and how far along. */ + var pairBusy by mutableStateOf(null) + var pairPercent by mutableIntStateOf(-1) + /** What Auto last did or found, for the line under the pair in use. */ + var autoStatus by mutableStateOf("") + /** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */ + var activeBundle by mutableStateOf(null) + private var autoCheckedThisProcess = false + + fun state() = GpuDriversState( + gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText, + supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED, + auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent, + autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking, + linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected, + linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled, + activeBundle = activeBundle, + ) var androidRows by mutableStateOf>(emptyList()) var androidSelected by mutableStateOf("") fun refreshDrivers() { val lm = LinuxVulkanDriverManager(activity) fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED + val bundles = DriverBundle.all(activity) + fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let { + DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE) + } linuxRows = LinuxVulkanDriver.optionValues(activity).map { id -> if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false) - else DriverRow( + else bundleRow(id, linux = true) ?: DriverRow( id, lm.getDriverName(id), listOfNotNull( lm.getDriverVersion(id).takeIf { it.isNotEmpty() }, @@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl true, origin(id), ) } - linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM) - linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP) + linuxSelected = SessionPrefs.linuxDriver(activity) + mode = SessionPrefs.gpuDriverMode(activity) val td = TurnipDriver(activity) val auto = td.autoId() androidRows = buildList { @@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl false, )) for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED)) - for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) + for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) } canRestoreBundled = td.hiddenBundled().isNotEmpty() androidSelected = SessionPrefs.androidDriver(activity) + activeBundle = DriverBundle.active(activity)?.label refreshReleaseRows() + refreshPairs() + } + + /** One line for the settings row that opens this: "Auto · WinNative · Balanced". */ + fun summary(): String { + val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle + return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "") + } + + /** The pairs the last check found, with what is installed and in use; this GPU's first. */ + fun refreshPairs() { + val lm = LinuxVulkanDriverManager(activity) + val td = TurnipDriver(activity) + val pairs = DriverPairs.from(TurnipReleases.cached(activity)) + val recommended = DriverPairs.recommendedKey(gpu, pairs) + pairRows = pairs.map { p -> + val bundle = p.bundle?.let { installedBundle(it) } + val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) } + val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) } + val mb = p.assets.sumOf { it.size } / 1_048_576.0 + PairRow( + key = p.key, name = p.name, version = p.version, + detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb), + recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete, + installed = displayId != null && linuxId != null, + active = displayId != null && linuxId != null && + SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId, + ) + }.sortedByDescending { it.recommended } + } + + fun setMode(auto: Boolean) { + mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL + SessionPrefs.setGpuDriverMode(activity, mode) + autoStatus = "" + if (auto) ensureAuto(force = false) + } + + /** + * Auto: make the recommended pair for this GPU the one installed and set. Looks online when + * [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and + * a new pair a day is plenty - otherwise works from the last check. Once per app start on its + * own; the pair it replaces is removed, anything the user imported or picked is left alone. + */ + fun ensureAuto(force: Boolean) { + mode = SessionPrefs.gpuDriverMode(activity) + if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return + if (!force && autoCheckedThisProcess) return + autoCheckedThisProcess = true + if (DriverPairs.recommendedKey(gpu, emptyList()) == null) { + autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}" + return + } + releaseChecking = true + autoStatus = "Checking for the latest drivers…" + Thread({ + val cached = TurnipReleases.cached(activity) + val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L || + cached.assets.none { it.pair.isNotEmpty() } + val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null + ui.post { + releaseChecking = false + refreshReleaseRows() + refreshPairs() + val pairs = DriverPairs.from(TurnipReleases.cached(activity)) + val key = DriverPairs.recommendedKey(gpu, pairs) + val pair = pairs.firstOrNull { it.key == key } + val row = pairRows.firstOrNull { it.key == key } + when { + row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "") + pair == null || !pair.complete -> + autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now" + else -> installPair(pair, auto = true) + } + } + }, "gpu-driver-auto").start() + } + + /** Manual: install (if need be) and set both halves of a pair. */ + fun selectPair(key: String) { + if (pairBusy != null) return + val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return + installPair(pair, auto = false) + } + + /** + * Download what is missing of [pair], then set both halves - only once both are in, so a + * failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the + * ones the new pair replaces are removed. + */ + private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) { + if (!pair.complete) return + pairBusy = pair.key + pairPercent = 0 + if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…" + Thread({ + val lm = LinuxVulkanDriverManager(activity) + val td = TurnipDriver(activity) + var downloaded = false + val result = runCatching { + val zips = pair.assets + val ids = zips.mapIndexed { i, asset -> + installedIdOf(asset) + ?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true } + } + if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids + } + ui.post { + pairBusy = null + pairPercent = -1 + result.onSuccess { (displayId, linuxId) -> + val displayChanged = SessionPrefs.androidDriver(activity) != displayId + SessionPrefs.setAndroidDriver(activity, displayId) + SessionPrefs.setLinuxDriver(activity, linuxId) + val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else "" + if (auto) { + val previous = SessionPrefs.gpuAutoInstalled(activity) + for (id in previous - setOf(displayId, linuxId)) { + val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id)) + when { + bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) } + lm.isInstalled(id) -> lm.removeDriver(id) + else -> td.remove(id) + } + TurnipReleases.forget(activity, id) + } + SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId)) + autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart" + } else { + android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show() + } + }.onFailure { e -> + Log.w(TAG, "driver pair ${pair.key}", e) + val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}" + if (auto) autoStatus = why ?: "Download failed" + else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show() + } + refreshDrivers() + } + }, "gpu-driver-pair").start() + } + + /** The bundle a release bundle was downloaded as, while both its halves are installed. */ + private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? = + TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) } + + /** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */ + private fun installedIdOf(asset: TurnipReleases.Asset): String? = when { + asset.bundle -> installedBundle(asset)?.id + asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled) + else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled) + } + + /** + * One release asset, downloaded, checked and installed through the importer; returns its id, + * a bundle's id for a bundle. + */ + private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String { + var file: File? = null + try { + file = TurnipReleases.download(activity, asset, progress) + val uri = Uri.fromFile(file) + val id = when { + asset.bundle -> DriverBundle.install(activity, uri).id + asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name) + else -> TurnipDriver(activity).installFromZip(uri, asset.name) + } + TurnipReleases.recordDownload(activity, asset, id) + return id + } finally { + file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } + } } /** * Import off the main thread - a driver zip is a few MB and the glibc check reads the whole - * library - then say what happened. A refusal's message is the user-facing reason. + * library - then say what happened. A refusal's message is the user-facing reason. A bundle, + * from any import, installs both halves and is set as both drivers at once. [linux] null (the + * tab's own Import .zip) takes a single driver to whichever list its libc belongs in. */ - fun importDriver(uri: Uri, linux: Boolean) { + fun importDriver(uri: Uri, linux: Boolean?) { val name = activity.displayNameOf(uri) Thread({ + var bundle: DriverBundle.Bundle? = null val problem = try { - if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name) - else TurnipDriver(activity).installFromZip(uri, name) + when { + DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri) + linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name) + linux == false -> TurnipDriver(activity).installFromZip(uri, name) + else -> try { + TurnipDriver(activity).installFromZip(uri, name) + } catch (display: IllegalArgumentException) { + try { + LinuxVulkanDriverManager(activity).installDriver(uri, name) + } catch (runtime: IllegalArgumentException) { + throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle") + } + } + } null } catch (e: IllegalArgumentException) { e.message @@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl "Import failed: ${e.message}" } ui.post { + val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}" android.widget.Toast.makeText( - activity, problem ?: "Imported ${name ?: "driver"}", - if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, + activity, problem ?: done, + if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, ).show() refreshDrivers() } }, "import-driver").start() } + /** + * Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half + * sets the bundle's other half in the other list as well. + */ + fun selectDriver(id: String, linux: Boolean) { + val bundle = DriverBundle.containing(activity, id, linux) + when { + bundle != null -> useBundle(bundle) + linux -> SessionPrefs.setLinuxDriver(activity, id) + else -> SessionPrefs.setAndroidDriver(activity, id) + } + setMode(false) + refreshDrivers() + } + + /** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */ + private fun useBundle(bundle: DriverBundle.Bundle): String { + val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId + DriverBundle.select(activity, bundle) + if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false) + return "Using ${bundle.label} as both the runtime and display driver." + + if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else "" + } + /** * Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a * session never starts on a driver that is gone; a release download is forgotten, so the menu * offers it again. */ fun deleteDriver(id: String, linux: Boolean) { + val bundle = DriverBundle.containing(activity, id, linux) + if (bundle != null) { + DriverBundle.remove(activity, bundle) + TurnipReleases.forget(activity, bundle.id) + android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show() + refreshDrivers() + return + } if (linux) { LinuxVulkanDriverManager(activity).removeDriver(id) - for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { - if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "") - } + if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "") } else { val td = TurnipDriver(activity) if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id) @@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl /** The download entries and the refresh line, from what the last check found. */ fun refreshReleaseRows() { val check = TurnipReleases.cached(activity) - val lm = LinuxVulkanDriverManager(activity) - val td = TurnipDriver(activity) fun rows(linux: Boolean) = check?.assets.orEmpty() - .filter { it.linux == linux } - .filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null } + .filter { it.bundle || it.linux == linux } + .filter { a -> installedIdOf(a) == null } .map { a -> val mb = "%.1f MB".format(a.size / 1_048_576.0) com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name]) @@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl fun checkLatestTurnip() { if (releaseChecking) return releaseChecking = true - releaseStatus = "Checking Banners-Turnip and WinNative…" + releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…" Thread({ val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) { Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed" @@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl ui.post { releaseChecking = false refreshReleaseRows() + refreshPairs() if (problem != null) releaseStatus = "Couldn't check: $problem" } }, "turnip-release-check").start() @@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl releaseProgress[assetName] = 0 refreshReleaseRows() Thread({ - var file: java.io.File? = null val problem = try { - file = TurnipReleases.download(activity, asset) { pct -> - ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } - } - val uri = Uri.fromFile(file) - val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name) - else TurnipDriver(activity).installFromZip(uri, asset.name) - TurnipReleases.recordDownload(activity, asset, id) + installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } } null } catch (e: IllegalArgumentException) { e.message } catch (e: Exception) { Log.w(TAG, "release driver download", e) "Download failed: ${e.message}" - } finally { - file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } } ui.post { releaseProgress.remove(assetName) + val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu" android.widget.Toast.makeText( - activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu", + activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick", android.widget.Toast.LENGTH_LONG, ).show() refreshDrivers() diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ae962bb76..8c82ed818 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -28,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 @@ -101,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) @@ -134,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) @@ -158,6 +160,12 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } + 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) @@ -167,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) { @@ -236,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) @@ -337,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, @@ -414,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. @@ -628,11 +660,11 @@ class MainActivity : ComponentActivity() { } } - /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ + /** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val all = ComponentsManager.COMPONENTS + val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1 components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size] } @@ -649,8 +681,11 @@ 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. + drivers.ensureAuto(force = false) // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the // session has just ended). Cheap when nothing is queued. if (!SessionState.running) Thread({ @@ -777,14 +812,16 @@ class MainActivity : ComponentActivity() { ) } - private fun openComponents(focusContent: Boolean = true) { + private fun openComponents(focusContent: Boolean = true, tab: String? = null) { focusComponentsContent = focusContent + if (tab != null) components.compComp = tab settingsMode = null showPerformance = false showProtons = false showMapping = false showComponents = true components.refreshComponents(snapshotFirst = true) + drivers.refreshDrivers() } private fun importComponent(uri: Uri) { @@ -819,6 +856,22 @@ class MainActivity : ComponentActivity() { onRefresh = { components.refreshComponentCatalog() }, onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) }, onBack = { showComponents = false }, + gpu = drivers.state(), + gpuActions = com.droiddeck.launcher.ui.GpuDriversActions( + onAuto = { on -> drivers.setMode(on) }, + onPair = { key -> drivers.selectPair(key) }, + onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() }, + // Picking one driver on its own is Manual: Auto would put its pair back. + onSelectLinux = { id -> drivers.selectDriver(id, linux = true) }, + onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) }, + onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, + onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) }, + onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) }, + onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) }, + onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) }, + onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) }, + onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, + ), ) } @@ -885,10 +938,8 @@ class MainActivity : ComponentActivity() { ModeSettingsPage( ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, - hdr = hdrOn, hdrReason = hdrReason, - linuxRows = drivers.linuxRows, - linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop, - androidRows = drivers.androidRows, androidSelected = drivers.androidSelected, + hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, + gpuDrivers = drivers.summary(), touchMode = touchMode, suspendPolicy = suspendPolicy, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, @@ -909,8 +960,6 @@ class MainActivity : ComponentActivity() { addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), addedGamesArt = addedGamesArt, - linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus, - releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled, deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null, deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null, deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent, @@ -921,15 +970,8 @@ class MainActivity : ComponentActivity() { onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, - onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() }, - onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) }, - onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, - onRefreshReleases = { drivers.checkLatestTurnip() }, - onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) }, - onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, - onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() }, - onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) }, - onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) }, + 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 }, @@ -995,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 }, @@ -1003,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 -> @@ -1055,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) @@ -1100,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 1c58332a7..49160f70c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -24,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 @@ -33,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 @@ -170,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) @@ -187,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()) @@ -216,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. @@ -338,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() @@ -361,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, @@ -371,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, @@ -581,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) @@ -778,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 @@ -887,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. */ @@ -1259,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/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 84c1d038b..96e495b53 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -113,19 +113,24 @@ object DeviceReport { k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) h("Drivers") + val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect() + k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") + k("Driver mode", SessionPrefs.gpuDriverMode(context) + + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( + com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: "")) val turnip = TurnipDriver(context) val androidChoice = SessionPrefs.androidDriver(context) k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice) k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim()) k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", ")) val lm = LinuxVulkanDriverManager(context) - for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { - val id = SessionPrefs.linuxDriver(context, m) + run { + val id = SessionPrefs.linuxDriver(context) val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)" else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() + lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } + if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]" - k("Linux driver ($m)", label) + k("Linux driver", label) } k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 6d833d931..3a1510402 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -12,6 +12,8 @@ import java.util.zip.CRC32 /** Windows game folders shared with the Steam session. */ object AddedGames { private const val TAG = "AddedGames" + private const val LIBRARY = "/mnt/droiddeck-sd" + private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd" class Game( val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String, @@ -65,7 +67,7 @@ object AddedGames { if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/") } GameStorage.effective(context)?.let { lib -> - if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/") + if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/") } return null } @@ -108,7 +110,8 @@ object AddedGames { if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return } val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return val name = folder.name - val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value + // Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put. + val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value val appId = crc or 0x80000000L val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") val steamId = steamRoute(steamRoot, appId) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt new file mode 100644 index 000000000..169682101 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt @@ -0,0 +1,196 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.zip.ZipInputStream + +/** + * An all-in-one driver package: one zip carrying both halves of a matched pair, the display + * (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another, + * indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers): + * + * ``` + * manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}} + * android/ meta.json (AdrenoTools) + libvulkan_freedreno.so + * linux/ meta.json + libvulkan_freedreno.so + * ``` + * + * Installing one puts each half through its own importer, with that importer's checks, and + * remembers the two as one bundle: picking either half sets both, and deleting either deletes both, + * so a bundle is always used whole. Zips of the older kind, one driver each, never reach this. + */ +object DriverBundle { + private const val TAG = "DriverBundle" + private const val PREFS = "driver_bundles" + private const val MANIFEST = "manifest.json" + /** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */ + private const val MAX_BYTES = 512L shl 20 + + class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String) + + /** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */ + class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) { + /** "DD-Turnip 0.1.0". */ + val label: String get() = if (version.isEmpty()) name else "$name $version" + } + + /** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */ + fun parseManifest(json: JSONObject): Manifest? { + val drivers = json.optJSONObject("drivers") ?: return null + val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null + val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null + if (android == linux) return null + return Manifest( + json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"), + json.optString("tag"), android, linux, + ) + } + + private fun cleanPath(path: String): String? { + val p = path.trim().trim('/') + return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } } + } + + /** True when the zip at [uri] is a bundle rather than a single driver. */ + fun isBundle(context: Context, uri: Uri): Boolean = try { + context.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input).use { zip -> + generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST } + ?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null + } + } ?: false + } catch (e: Exception) { + Log.w(TAG, "reading $uri as a bundle", e) + false + } + + /** + * Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes + * the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip + * is not a usable bundle, [IOException] on read failures. + */ + fun install(context: Context, uri: Uri): Bundle { + val work = File(context.cacheDir, "bundle-${System.nanoTime()}") + val td = TurnipDriver(context) + val lm = LinuxVulkanDriverManager(context) + var androidId: String? = null + try { + unzip(context, uri, work) + val manifest = File(work, MANIFEST).takeIf { it.isFile } + ?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() } + ?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver") + val androidDir = File(work, manifest.androidPath) + val linuxDir = File(work, manifest.linuxPath) + + val androidStage = td.newStagingDir() + androidId = try { + androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) } + td.adopt(androidStage, manifest.name) + } catch (e: Exception) { + FileUtils.delete(androidStage) + throw e + } + + val linuxStage = lm.newStagingDir() + val linuxId = try { + val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") } + library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME)) + val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile } + ?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() } + lm.adopt(linuxStage, library?.name, meta, manifest.name) + } catch (e: Exception) { + FileUtils.delete(linuxStage) + throw e + } + + val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" }) + val saved = registry(context) + var id = base + var n = 2 + while (saved.contains(id)) id = "$base-${n++}" + val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId) + saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version) + .put("android", androidId).put("linux", linuxId).toString()).apply() + Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId") + return bundle + } catch (e: Exception) { + androidId?.let { td.remove(it) } + throw e + } finally { + FileUtils.delete(work) + } + } + + private fun unzip(context: Context, uri: Uri, dest: File) { + val root = dest.canonicalFile + var total = 0L + context.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input).use { zip -> + for (entry in generateSequence { zip.nextEntry }) { + if (entry.isDirectory) continue + val out = File(root, entry.name).canonicalFile + if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it") + out.parentFile?.mkdirs() + out.outputStream().use { sink -> + val buf = ByteArray(1 shl 16) + while (true) { + val r = zip.read(buf) + if (r <= 0) break + total += r + if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB") + sink.write(buf, 0, r) + } + } + } + } + } ?: throw IOException("cannot open $uri") + } + + /** Every installed bundle whose two halves are both still there. */ + fun all(context: Context): List { + val td = TurnipDriver(context) + val lm = LinuxVulkanDriverManager(context) + return registry(context).all.mapNotNull { (id, raw) -> + runCatching { + val j = JSONObject(raw as String) + Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux")) + }.getOrNull() + }.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id } + } + + fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id } + + /** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */ + fun containing(context: Context, driverId: String, linux: Boolean): Bundle? = + all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId } + + /** The bundle both drivers are set to right now, if they are one. */ + fun active(context: Context): Bundle? { + val android = SessionPrefs.androidDriver(context) + val linux = SessionPrefs.linuxDriver(context) + return all(context).firstOrNull { it.androidId == android && it.linuxId == linux } + } + + /** Set both halves as the drivers in use. */ + fun select(context: Context, bundle: Bundle) { + SessionPrefs.setAndroidDriver(context, bundle.androidId) + SessionPrefs.setLinuxDriver(context, bundle.linuxId) + } + + /** Delete both halves; a driver still set to either goes back to its default. */ + fun remove(context: Context, bundle: Bundle) { + TurnipDriver(context).remove(bundle.androidId) + LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId) + if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "") + registry(context).edit().remove(bundle.id).apply() + Log.i(TAG, "removed bundle ${bundle.id}") + } + + private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt new file mode 100644 index 000000000..f3b28fe38 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt @@ -0,0 +1,112 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family + +/** + * The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux + * build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a + * patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app + * installs and selects both halves of one pair at once. + * + * - Banners-Turnip publishes each variant as a set in every release (`Turnip-.zip` + * and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the + * newest release that carries both halves. + * - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with + * their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance). + * - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the + * newest bundle, and it can never be mixed. + */ +object DriverPairs { + const val BANNER = "banner" + const val BANNER_A8XX = "banner-a8xx" + const val BANNER_710 = "banner-710-720-test" + const val BANNER_ONEUI = "banner-8g2-oneui" + const val WN_BALANCED = "wn-b" + const val WN_PERFORMANCE = "wn-p" + const val DD_TURNIP = "dd-turnip" + /** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */ + fun ddTurnip(tag: String) = "$DD_TURNIP:$tag" + + class DriverPair( + val key: String, + /** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */ + val name: String, + /** The families the pair is built for; the list only offers it on those unless asked. */ + val families: Set, + val display: TurnipReleases.Asset?, + val linux: TurnipReleases.Asset?, + /** Both halves in one zip, instead of [display] and [linux]. */ + val bundle: TurnipReleases.Asset? = null, + ) { + val complete: Boolean get() = bundle != null || (display != null && linux != null) + /** The zips to download for the pair: the bundle, or the two halves. */ + val assets: List get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null) + /** "r4 · 1.19 + 0.1.2": the build of each half. */ + val version: String + get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") } + fun suits(gpu: GpuInfo) = gpu.family in families || + (gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families)) + } + + private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN) + + private fun describe(key: String): Pair> = when { + key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO + else -> describeFixed(key) + } + + private fun describeFixed(key: String): Pair> = when (key) { + BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX) + BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX) + BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW) + BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX) + WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO + WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO + else -> key to ALL_ADRENO + } + + /** Every pair the last release check found, in the order the list shows them. */ + fun from(check: TurnipReleases.Check?): List { + val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() } + val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE) + return assets.map { it.pair }.distinct() + .sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } } + .map { key -> + val (name, families) = describe(key) + val halves = assets.filter { it.pair == key } + halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) } + // The list keeps the newest release of each half; for Banners-Turnip the two halves + // must come from the same release, so a set missing one half in its newest release + // is offered incomplete rather than mixed with an older one. + val display = halves.firstOrNull { !it.linux } + val linux = halves.firstOrNull { it.linux } + val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag + DriverPair(key, name, families, display, if (sameRelease) linux else null) + } + } + + /** + * The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest + * official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other + * 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip + * release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for + * the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno). + */ + fun recommendedKey(gpu: GpuInfo, pairs: List): String? = when (gpu.family) { + Family.NOT_ADRENO -> null + Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED + Family.A7XX_LOW -> BANNER_710 + Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER + Family.A6XX -> BANNER + // KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one. + Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER + } + + /** The complete DD-Turnip pair with the highest version, or null when none is listed. */ + fun newestDdTurnip(pairs: List): String? = pairs + .filter { it.key.startsWith("$DD_TURNIP:") && it.complete } + .maxByOrNull { p -> + Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured + ?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L + }?.key +} 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/GpuInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt new file mode 100644 index 000000000..3054f0733 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.gpu + +import android.os.Build +import com.droiddeck.launcher.core.FileUtils +import java.io.File + +/** + * What GPU this is, in the terms the driver lists are sorted by. KGSL names the model + * ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the + * support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610 + * may start, but it is not "supported" just because it is a Qualcomm chip. + */ +data class GpuInfo( + /** "Adreno 740", or the vendor's own name when this is not an Adreno. */ + val name: String, + /** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */ + val model: Int, + val family: Family, + /** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */ + val soc: String, + /** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */ + val oneUi8Gen2: Boolean, +) { + enum class Family(val label: String) { + A8XX("Adreno 8xx"), + /** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */ + A7XX_LOW("Adreno 710/720/722"), + A7XX("Adreno 7xx"), + A6XX("Adreno 6xx"), + /** An Adreno whose model KGSL does not give: treated as the newest family it could be. */ + ADRENO_UNKNOWN("Adreno"), + NOT_ADRENO("Not an Adreno GPU"), + } + + enum class Support { TESTED, UNTESTED, UNSUPPORTED } + + val support: Support + get() = when { + family == Family.NOT_ADRENO -> Support.UNSUPPORTED + family == Family.A8XX -> Support.TESTED + family == Family.A7XX && model >= 725 -> Support.TESTED + else -> Support.UNTESTED + } + + /** One line for the device card and the system check. */ + val supportText: String + get() = when (support) { + Support.TESTED -> "Supported" + Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds" + else "Below tested hardware (Adreno 725 and newer): it may not run" + Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU" + } + + companion object { + fun detect(): GpuInfo { + val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists() + val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid") + .firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) } + val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else "" + val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0 + val family = familyOf(adreno, model) + val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true) + return GpuInfo( + name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno", + model = model, family = family, soc = soc, + oneUi8Gen2 = samsung && model == 740, + ) + } + + internal fun familyOf(adreno: Boolean, model: Int): Family = when { + !adreno -> Family.NOT_ADRENO + model == 0 -> Family.ADRENO_UNKNOWN + model >= 800 -> Family.A8XX + model in listOf(710, 720, 722) -> Family.A7XX_LOW + model >= 700 -> Family.A7XX + model >= 600 -> Family.A6XX + else -> Family.ADRENO_UNKNOWN + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java index 077e230cd..c0cb48f08 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java @@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager { * Linux driver; {@link IOException} on read/extract failures. */ public String installDriver(Uri zipUri, String displayName) throws IOException { - File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis()); - FileUtils.delete(tmpDir); - if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + File tmpDir = newStagingDir(); boolean keep = false; try { String soName = null; @@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager { } } // The zip's own freedreno_icd.aarch64.json is dropped on purpose: its - // library_path is relative to itself, and we write an absolute one below. + // library_path is relative to itself, and adopt() writes an absolute one. } } + String id = adopt(tmpDir, soName, zipMeta, displayName); + keep = true; + return id; + } finally { + if (!keep) FileUtils.delete(tmpDir); + } + } + + /** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */ + File newStagingDir() throws IOException { + File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime()); + FileUtils.delete(tmpDir); + if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + return tmpDir; + } + + /** + * Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in + * the zip), write its manifest and meta.json, and move it in. Returns the id; the caller + * deletes {@code tmpDir} when this throws. + */ + String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException { + try { if (soName == null) { throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android " + "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver."); @@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager { if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json"); if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); - keep = true; Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir); return id; } catch (org.json.JSONException e) { throw new IOException("manifest write failed: " + e.getMessage()); - } finally { - if (!keep) FileUtils.delete(tmpDir); } } 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/gpu/TurnipDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java index 8d8863961..52d8129ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java @@ -179,10 +179,7 @@ public final class TurnipDriver { * {@link IOException} on read/extract failures. */ public String installFromZip(Uri zipUri, String displayName) throws IOException { - if (!contentDir.exists()) contentDir.mkdirs(); - File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis()); - FileUtils.delete(tmpDir); - if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + File tmpDir = newStagingDir(); boolean keep = false; try { try (InputStream is = context.getContentResolver().openInputStream(zipUri); @@ -198,26 +195,44 @@ public final class TurnipDriver { Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING); } } - String rejected = rejectionReason(tmpDir); - if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected); - - JSONObject meta = readMetaFile(new File(tmpDir, "meta.json")); - String name = meta != null ? meta.optString("name", "") : ""; - if (name.isEmpty()) { - name = displayName != null ? displayName : "adrenotools-driver"; - if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4); - } - String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name)); - File dir = new File(contentDir, id); - if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); + String id = adopt(tmpDir, displayName); keep = true; - Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir); return id; } finally { if (!keep) FileUtils.delete(tmpDir); } } + /** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */ + File newStagingDir() throws IOException { + if (!contentDir.exists()) contentDir.mkdirs(); + File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime()); + FileUtils.delete(tmpDir); + if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + return tmpDir; + } + + /** + * Check a staged driver (meta.json and its library, flat) and move it in as a new driver. + * Returns the id; the caller deletes {@code tmpDir} when this throws. + */ + String adopt(File tmpDir, String displayName) throws IOException { + String rejected = rejectionReason(tmpDir); + if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected); + + JSONObject meta = readMetaFile(new File(tmpDir, "meta.json")); + String name = meta != null ? meta.optString("name", "") : ""; + if (name.isEmpty()) { + name = displayName != null ? displayName : "adrenotools-driver"; + if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4); + } + String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name)); + File dir = new File(contentDir, id); + if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); + Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir); + return id; + } + private static JSONObject readMetaFile(File metaFile) { try { return new JSONObject(FileUtils.readString(metaFile)); diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index 14e7e81e6..9a6235f1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -29,12 +29,20 @@ object TurnipReleases { /** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */ /** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */ + /** + * [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux + * build of the same pair are made to run together. "" for a list cached before pairs were kept. + */ + /** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */ class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, - val sha256: String = "") + val sha256: String = "", val pair: String = "", val bundle: Boolean = false) /** [latest] = each source's newest release tag, for the refresh line. */ class Check(val assets: List, val latest: List>, val failed: List, val checkedAt: Long) - private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair?) + /** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */ + private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false) + + private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?) private val SOURCES = listOf( // Turnip-[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have. @@ -45,12 +53,14 @@ object TurnipReleases { if (variant.endsWith("-Wayland")) return@Source null val linux = variant.endsWith("-Linux") variant = variant.removeSuffix("-Linux") - linux to when { + val label = when { variant.isEmpty() -> "Adreno 6xx/7xx" variant == "-A8xx" -> "Adreno 8xx" variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else "" + variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI" else -> variant.trimStart('-') } + Kind(linux, label, "banner" + variant.lowercase()) }, // WN-Turnip--_Axxx.zip (Android) and WN-Linux-Turnip--_Axxx.zip (Linux): // b = Balanced, p = Performance, Axxx = every Adreno. @@ -58,7 +68,13 @@ object TurnipReleases { val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] } val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3] - (m.groupValues[1].isNotEmpty()) to "$gpus · $flavour" + Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2]) + }, + // DD-Turnip-v.zip: DroidDeck's own all-in-one build, both halves in one zip, published + // to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair. + Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag -> + if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null + Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true) }, ) @@ -91,14 +107,19 @@ object TurnipReleases { for (j in 0 until list.length()) { val a = list.getJSONObject(j) val name = a.getString("name") - val (linux, label) = src.classify(name, tag) ?: continue + val kind = src.classify(name, tag) ?: continue + // A bundle is only offered from an official release, never a pre-release. + if (kind.bundle && r.optBoolean("prerelease")) continue + val linux = kind.linux + val label = kind.label // Only assets GitHub has a sha256 for are offered: the download is checked against it. val sha = Hashes.githubSha256(a.optString("digest")) ?: continue // Newest first: the first release carrying a variant is the one offered. - if (!seen.add("$linux|$label")) continue + if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name) .put("url", a.getString("browser_download_url")).put("size", a.optLong("size")) - .put("linux", linux).put("label", label).put("sha256", sha)) + .put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair) + .put("bundle", kind.bundle)) } } if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest)) @@ -136,7 +157,8 @@ object TurnipReleases { for (i in 0 until list.length()) { val a = list.getJSONObject(i) out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"), - a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""))) + a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""), + a.optBoolean("bundle", false))) } val latest = ArrayList>() val l = json.optJSONArray("latest") ?: JSONArray() 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 4a0312cad..9eb8a1d35 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -112,15 +112,52 @@ object SessionPrefs { } /** - * The imported glibc Turnip a mode draws with inside the runtime, keyed by - * SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the - * driver built into the runtime. Resolved by LinuxVulkanDriver at session start. + * The imported glibc Turnip every session draws with inside the runtime - Steam, its games and + * the desktop alike, as they run on the same GPU; "" = the driver built into the runtime. + * Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam + * session's choice, the one nearly everyone set, carries over. */ - fun linuxDriver(context: Context, mode: String): String = - prefs(context).getString("linuxDriver.$mode", "") ?: "" + fun linuxDriver(context: Context): String = + prefs(context).getString("linuxDriver", null) + ?: prefs(context).getString("linuxDriver.steam", null) + ?: prefs(context).getString("linuxDriver.desktop", "") ?: "" - fun setLinuxDriver(context: Context, mode: String, id: String) { - prefs(context).edit().putString("linuxDriver.$mode", id).apply() + fun setLinuxDriver(context: Context, id: String) { + prefs(context).edit().putString("linuxDriver", id).apply() + } + + const val GPU_DRIVERS_AUTO = "auto" + const val GPU_DRIVERS_MANUAL = "manual" + + /** + * Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for + * this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new + * installs; see settleGpuDriverMode. + */ + fun gpuDriverMode(context: Context): String = + prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO + + fun setGpuDriverMode(context: Context, mode: String) { + prefs(context).edit().putString("gpuDriverMode", mode).apply() + } + + /** + * Auto arrived after people had picked drivers by hand: an install that chose either driver + * keeps its choice (Manual), everyone else is Auto. Decided once, at process start. + */ + fun settleGpuDriverMode(context: Context) { + val p = prefs(context) + if (p.contains("gpuDriverMode")) return + val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty() + p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply() + } + + /** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */ + fun gpuAutoInstalled(context: Context): Set = + prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty() + + fun setGpuAutoInstalled(context: Context, ids: Set) { + prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply() } /** @@ -246,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) { @@ -519,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. @@ -539,7 +599,7 @@ object SessionPrefs { // ── Game storage ──────────────────────────────────────────────────────────────────────── /** - * A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and + * A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and * registered with the client, which then asks where to install every game and shows both * on its Storage page. "" = automatic: the SD card when one is in the phone (the default, * so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal 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 27dc9ac5f..4114c627e 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)) @@ -498,15 +503,12 @@ class SessionService : Service() { guest.add("GALLIUM_DRIVER=zink") guest.add("LIBGL_KOPPER_DRI2=true") LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) } - // An imported glibc Turnip for this mode, when the user chose one: the session script checks - // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, - // so the runtime's own driver above stays untouched and is what a bad import falls back to. - // A program from the rail is one of the desktop's emulators, so it draws with the desktop's - // Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver - // build, and with two drivers every emulator compiled its shaders twice - once per way of - // starting it (RPCS3's 6650 interpreter variants on each first boot). - val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode) - val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode) + // An imported glibc Turnip, when one is set (by the user, or by Auto): the session script + // checks the manifest and its library from inside and points the loader at it with + // VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad + // import falls back to. One driver for every session: the driver's shader cache is keyed on + // its build, and with one per mode every emulator compiled its shaders twice. + val linuxDriverId = SessionPrefs.linuxDriver(this) LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } // Turnip's own debug switches, for the runtime's driver and everything on it. The file in @@ -527,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)) @@ -689,9 +697,12 @@ class SessionService : Service() { if (library != null) { val problem = GameStorage.prepare(library.path) if (problem == null) { + File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs() + binds.add("${library.path}:/mnt/droiddeck-sd") + // Links, prefixes and Steam entries made before the rename still name the old path. File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs() binds.add("${library.path}:/mnt/bannerlator-sd") - Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")") + Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")") } else { Log.w(TAG, "game storage: $problem; internal only this session") } @@ -967,6 +978,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 @@ -975,6 +1011,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() @@ -1151,6 +1188,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 566f66b4e..67fc47be7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -102,6 +102,9 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, requestInitialFocus: Boolean = true, + /** The GPU drivers tab ([GPU_TAB]): what it shows and does. */ + gpu: GpuDriversState = GpuDriversState(), + gpuActions: GpuDriversActions = GpuDriversActions(), ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -112,23 +115,55 @@ fun ComponentsPage( var about by remember { mutableStateOf(false) } var protonMenu by remember { mutableStateOf(false) } fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action } + // The GPU drivers tab's Advanced pages: one driver list on its own, full page. + var driverPage by remember { mutableStateOf(null) } + val gpuTab = comp == GPU_TAB + when (driverPage) { + "rt" -> { + DriverPage( + title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.", + rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false, + onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, + ) + return + } + "panel" -> { + DriverPage( + title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.", + rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…", + canRestore = gpu.canRestoreBundled, + onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, + onBack = { driverPage = null }, + ) + return + } + } val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() val label = ComponentsManager.LABEL[comp] ?: comp val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" - val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked" + val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked" Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { // ---- title, and the one thing that goes online ----------------------------------------- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Text( "Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, modifier = Modifier.weight(1f), + maxLines = 1, ) - if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - ToolIcon(Icons.Outlined.Info, "About Components") { about = true } - ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh) + // The status gives way to the title, not the other way round. + if (!narrow) Text( + nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f), + ) else Spacer(Modifier.weight(1f)) + if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true } + if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh) + else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh) } if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -138,7 +173,7 @@ fun ComponentsPage( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp), ) { - if (view != null) Box { + if (view != null && !gpuTab) Box { ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu } AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton", note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first -> @@ -152,11 +187,12 @@ fun ComponentsPage( } } } - TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) }) + val tabs = listOf(GPU_TAB) + comps + TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) }) } // ---- what is in use ----------------------------------------------------------------------- - if (view != null) { + if (view != null && !gpuTab) { val st = view.components.getValue(comp) val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else "" Row( @@ -179,6 +215,12 @@ fun ComponentsPage( // ---- the lists ------------------------------------------------------------------------------ when { + gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { + GpuDriversPanel(gpu, gpuActions) { driverPage = it } + Row(modifier = Modifier.padding(top = 14.dp)) { + SmallButton("Import .zip", onClick = gpuActions.onImportZip) + } + } snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(12.dp)) @@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } + +/** The Components page's tab for the GPU drivers, before the Proton components' own. */ +const val GPU_TAB = "gpu" 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 6a8809088..7b6722ccd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -141,6 +141,7 @@ fun DriverPage( text = { Text( if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it." + else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default." else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "", fontSize = 13.sp, ) 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/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 bc51f4036..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. @@ -151,7 +153,9 @@ internal fun SetupPanel( } val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val gpuOk = remember { DeviceSupport.adreno() } + // Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support. + val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() } + val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED val gpuName = remember { DeviceSupport.gpuName() } val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) val signedIn = s.offlineAccount != null @@ -193,8 +197,16 @@ internal fun SetupPanel( Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { CheckRow( if (gpuOk) CheckState.OK else CheckState.WARN, - if (gpuOk) "Device supported" else "GPU not supported", - if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen", + when (gpu.support) { + com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported" + com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU" + else -> "GPU not supported" + }, + when (gpu.support) { + com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName" + com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}" + else -> "Steam draws with an Adreno driver; $gpuName may show a black screen" + }, ) CheckRow( when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, @@ -269,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) @@ -383,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) } /** @@ -399,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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt new file mode 100644 index 000000000..bedab4c2b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -0,0 +1,312 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */ +class PairRow( + val key: String, + val name: String, + val version: String, + val detail: String, + val recommended: Boolean, + /** Built for this GPU's family; the others only show when asked. */ + val suits: Boolean, + val complete: Boolean, + val installed: Boolean, + val active: Boolean, +) + +class GpuDriversState( + val gpuName: String = "", + val gpuFamily: String = "", + val soc: String = "", + val supportText: String = "", + val supported: Boolean = true, + val unsupported: Boolean = false, + val auto: Boolean = true, + val pairs: List = emptyList(), + /** The pair key being installed, and how far along (-1 = unknown). */ + val busy: String? = null, + val percent: Int = -1, + val autoStatus: String = "", + val releaseStatus: String = "", + val checking: Boolean = false, + // Advanced: each driver on its own. + val linuxRows: List = emptyList(), + val linuxSelected: String = "", + val androidRows: List = emptyList(), + val androidSelected: String = "", + val linuxDownloads: List = emptyList(), + val androidDownloads: List = emptyList(), + val canRestoreBundled: Boolean = false, + /** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */ + val activeBundle: String? = null, +) { + /** The pair both drivers are set to, if they make one. */ + val activePair: PairRow? get() = pairs.firstOrNull { it.active } +} + +class GpuDriversActions( + val onAuto: (Boolean) -> Unit = {}, + val onPair: (String) -> Unit = {}, + val onRefresh: () -> Unit = {}, + val onSelectLinux: (String) -> Unit = {}, + val onSelectAndroid: (String) -> Unit = {}, + val onRemoveLinux: (String) -> Unit = {}, + val onRemoveAndroid: (String) -> Unit = {}, + val onDownloadDriver: (String) -> Unit = {}, + val onImportLinux: () -> Unit = {}, + val onImportAndroid: () -> Unit = {}, + /** The tab's own import: a bundle, or a single driver of either kind. */ + val onImportZip: () -> Unit = {}, + val onRestoreBundled: () -> Unit = {}, +) + +/** + * The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the + * games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps + * the pair recommended for the GPU installed and current; Manual picks a pair from the list; the + * two lists each on their own, and imports, are under Advanced. + */ +@Composable +internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var showAll by rememberSaveable { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + DeviceCard(s) + if (!s.unsupported) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) } + ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) } + } + if (s.auto) AutoCard(s) + else { + val shown = s.pairs.filter { it.suits || showAll } + if (shown.isEmpty()) Text( + if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp), + ) + for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) } + if (s.pairs.any { !it.suits }) FocusText( + if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal, + ) { showAll = !showAll } + } + } + SettingsGroup("Advanced") { + val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." } + SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") { + DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") } + } + SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") { + DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") } + } + } + } +} + +/** + * One driver list as a drop-down: the installed drivers to pick from, and the full page for + * downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both. + */ +@Composable +private fun DriverDropdown(rows: List, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var open by remember { mutableStateOf(false) } + Box { + SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true } + DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) { + for (row in rows) DropdownMenuItem( + leadingIcon = { Radio(row.id == selected) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant) + } + if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + onClick = { open = false; if (row.id != selected) onSelect(row.id) }, + ) + HorizontalDivider(color = pal.line) + DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() }) + } + } +} + +@Composable +private fun DeviceCard(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val tint = when { + s.unsupported -> pal.error + s.supported -> pal.good + else -> AttentionAmber + } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { + Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + } + Column(Modifier.weight(1f)) { + Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2) + } + } +} + +/** What Auto has set and what it is doing. */ +@Composable +private fun AutoCard(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val active = s.activePair + val recommended = s.pairs.firstOrNull { it.recommended } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), + ) { + Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) + Text( + active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck", + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + ) + Text( + if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair." + else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.", + fontSize = 13.sp, color = colors.onSurfaceVariant, + ) + if (s.busy != null) { + LinearProgressIndicator( + progress = { (s.percent.coerceAtLeast(0)) / 100f }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape), + ) + } + if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp)) + } +} + +@Composable +private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + 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(12.dp), + modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp) + .clip(Shape14).background(colors.surface).background(fill) + .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Column(Modifier.weight(1f)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Radio(selected) + } +} + +@Composable +private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val usable = enabled && p.complete && !p.active + val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp) + .clip(Shape12).background(colors.surface).background(fill) + .border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick) + .controllerConfirm(enabled = usable, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Radio(p.active) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (p.recommended) Text( + "RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good, + modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + when { + busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { + Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant) + LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal) + !p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant) + p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + } + } +} + +@Composable +private fun Radio(selected: Boolean) { + val pal = LocalPalette.current + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) { + if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal)) + } +} 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 fc41cd81e..d9f26e6b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova const val BUNDLED = "BUNDLED" const val DOWNLOADED = "DOWNLOADED" const val IMPORTED = "IMPORTED" + /** One half of an Android + Linux bundle: picked and deleted together with the other. */ + const val BUNDLE = "ANDROID + LINUX" } } @@ -47,10 +49,10 @@ class ModeSettings( val shapeMode: String, val hdr: Boolean, val hdrReason: String?, - val linuxRows: List, - val linuxSelected: String, - val androidRows: List, - val androidSelected: 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. */ @@ -81,12 +83,6 @@ class ModeSettings( val addedGames: List = emptyList(), val addedGamesArt: Boolean = true, /** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */ - val linuxDownloads: List = emptyList(), - val androidDownloads: List = emptyList(), - val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers", - val releaseChecking: Boolean = false, - /** A bundled display driver was deleted: the page offers to restore it. */ - val canRestoreBundled: Boolean = false, /** Steam only: Decky Loader is managed from the Steam session settings. */ val deckyInstalled: String? = null, val deckyLatestRelease: DeckyManager.Release? = null, @@ -106,16 +102,9 @@ class ModeSettingsActions( val onCustomResolution: (Pair?) -> Unit = {}, val onShape: (String) -> Unit, val onHdr: (Boolean) -> Unit, - val onSelectLinux: (String) -> Unit, - val onImportLinux: () -> Unit, - val onRemoveLinux: (String) -> Unit, - val onRefreshReleases: () -> Unit = {}, - /** Asset name of the release driver to download. */ - val onDownloadDriver: (String) -> Unit = {}, - val onRestoreBundled: () -> Unit = {}, - val onSelectAndroid: (String) -> Unit, - val onImportAndroid: () -> Unit, - val onRemoveAndroid: (String) -> 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, @@ -150,51 +139,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { val steam = s.mode == SessionService.MODE_STEAM val host = rememberMenuHost() var confirmDeckyRemoval by remember { mutableStateOf(false) } - // The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display). - // Coming back restores this page as it was left: the same scroll position, and controller focus - // on the driver box that opened the page. - var driverPage by remember { mutableStateOf(null) } - var returnTo by remember { mutableStateOf(null) } val pageScroll = androidx.compose.foundation.rememberScrollState() - val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() } - val displayChip = remember { androidx.compose.ui.focus.FocusRequester() } val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } - fun openDriverPage(key: String) { returnTo = key; driverPage = key } - androidx.compose.runtime.LaunchedEffect(driverPage) { - if (driverPage == null) { - // One frame first: the box has to be laid out before it can take focus. Opened from the - // cog, focus starts on the first control (Resolution) so the d-pad works at once; back - // from a driver page, it returns to the driver box that opened it. - androidx.compose.runtime.withFrameNanos { } - val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip } - runCatching { target.requestFocus() } - } - } - when (driverPage) { - "rt" -> { - DriverPage( - title = "Runtime driver", - hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.", - rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads, - status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false, - onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases, - onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null }, - ) - return - } - "panel" -> { - DriverPage( - title = "Display driver", - hint = "Used by the compositor in both modes. Restart the app to apply.", - rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads, - status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…", - canRestore = s.canRestoreBundled, - onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases, - onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled, - onBack = { driverPage = null }, - ) - return - } + androidx.compose.runtime.LaunchedEffect(Unit) { + // One frame first: the box has to be laid out before it can take focus. Opened from the + // cog, focus starts on the first control (Resolution) so the d-pad works at once. + androidx.compose.runtime.withFrameNanos { } + runCatching { firstChip.requestFocus() } } SettingsPage( host, @@ -220,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) }, @@ -235,17 +192,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } SettingsGroup("Drivers") { - SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") { - ValueChip( - s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false, - modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip), - ) { openDriverPage("rt") } - } - SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") { - ValueChip( - s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false, - modifier = androidx.compose.ui.Modifier.focusRequester(displayChip), - ) { openDriverPage("panel") } + SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") { + ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() } } } SettingsGroup(if (steam) "Touch & controls" else "Touch") { 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 501dd69b9..af38940f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -113,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 @@ -157,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, @@ -173,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... */ @@ -345,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) } } } 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 6d824a604..e1efe1b06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie if (LocalNarrowPane.current) { StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) + ChannelLabel() ChannelPicker(u, ua) - } else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { - Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } - Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } else { + // The label sits over both columns, so the status card lines up with the first channel. + ChannelLabel() + Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { + Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } + Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } } } } @@ -140,13 +145,13 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m val pal = LocalPalette.current val catalog = u.catalog val release = catalog?.let { AppUpdates.release(it, u.follow) } - val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null + val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null val name = channelName(u.follow) val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH) class Look(val tint: Color, val status: String, val headline: String, val detail: String?) val look = when { - !me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer", - "It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.") + !me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place", + "This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.") catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null) offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null) offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null) @@ -235,13 +240,20 @@ private fun changeTitle(t: String): String = private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB" /** The three channels as cards to pick from; Test builds opens its list of PRs under it. */ +@Composable +private fun ChannelLabel() { + Text( + "UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp), + ) +} + @Composable private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { val colors = MaterialTheme.colorScheme val catalog = u.catalog val tests = catalog?.tests.orEmpty() Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp)) ChannelCard( Icons.Outlined.Verified, "Stable", "Tested releases, for most people", catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE, diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 955ea2b1e..5d3eb1593 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher.update import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.core.Hashes import org.json.JSONArray @@ -8,6 +10,7 @@ import org.json.JSONObject import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest import java.time.Instant /** @@ -47,8 +50,11 @@ object AppUpdates { class Catalog(val stable: Release?, val nightly: Release?, val tests: List, val checkedAt: Long) - /** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */ - class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L) + /** + * What is running: the commit it was built from, its PR (0 for none), whether the builds here + * install over it (it is signed with the release key), and when it was committed. + */ + class Installed(val commit: String, val pr: Int, val version: String, val updatable: Boolean, val committedAt: Long = 0L) enum class Offer { /** This is the build the channel has. */ @@ -63,7 +69,28 @@ object AppUpdates { GONE, } - fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME) + @Volatile private var releaseSigned = BuildConfig.CI_BUILD + + /** From App.onCreate: whether this copy is signed with the release key, which a local build may be too. */ + fun init(context: Context) { + releaseSigned = runCatching { signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } } + .getOrDefault(BuildConfig.CI_BUILD) + } + + /** SHA-256 of each certificate the package is signed with, including the ones a rotated key vouches for. */ + private fun signers(context: Context): List { + val pm = context.packageManager + @Suppress("DEPRECATION") + val certs = if (Build.VERSION.SDK_INT >= 28) { + val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo ?: return emptyList() + if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory + } else { + pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures + } ?: return emptyList() + return certs.map { cert -> MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } } + } + + fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, releaseSigned, BuildConfig.COMMIT_TIME) fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) { Channel.STABLE -> catalog.stable @@ -87,7 +114,7 @@ object AppUpdates { /** The dot on the rail: the followed channel has a newer build of what is running. */ fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean = - catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE + catalog != null && installed().updatable && offer(catalog, follow) == Offer.UPDATE fun isRunning(r: Release, me: Installed = installed()): Boolean { if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false @@ -105,7 +132,7 @@ object AppUpdates { me.pr != 0 -> Follow(Channel.TEST, me.pr) catalog == null -> return Follow(Channel.STABLE) catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE) - me.ci -> Follow(Channel.NIGHTLY) + me.updatable -> Follow(Channel.NIGHTLY) else -> Follow(Channel.STABLE) } setFollow(context, inferred) 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/DriverBundleTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt new file mode 100644 index 000000000..434e87d09 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt @@ -0,0 +1,133 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.net.Uri +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +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 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class DriverBundleTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + File(context.filesDir, "graphics_driver").deleteRecursively() + File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively() + context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit() + SessionPrefs.setAndroidDriver(context, "") + SessionPrefs.setLinuxDriver(context, "") + } + + /** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */ + private fun library(libc: String) = ByteArray(64).also { + byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it) + it[18] = 0xB7.toByte() + } + libc.toByteArray() + ByteArray(1) + + private val manifest = JSONObject() + .put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0") + .put("drivers", JSONObject() + .put("android", JSONObject().put("path", "android")) + .put("linux", JSONObject().put("path", "linux"))) + + private fun zip(entries: Map): Uri { + val file = File.createTempFile("bundle", ".zip", context.cacheDir) + ZipOutputStream(file.outputStream()).use { z -> + for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() } + } + return Uri.fromFile(file) + } + + private fun bundle(linuxLibc: String = "libc.so.6", extra: Map = emptyMap()) = zip(mapOf( + "android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(), + "android/libvulkan_freedreno.so" to library("libc.so"), + "linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(), + "linux/libvulkan_freedreno.so" to library(linuxLibc), + "manifest.json" to manifest.toString().toByteArray(), + ) + extra) + + @Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() { + val m = DriverBundle.parseManifest(manifest)!! + assertEquals("android", m.androidPath) + assertEquals("linux", m.linuxPath) + assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a"))))) + val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") } + assertNull(DriverBundle.parseManifest(same)) + val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") } + assertNull(DriverBundle.parseManifest(escape)) + } + + @Test fun singleDriverZipsAreNotBundles() { + val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so"))) + assertFalse(DriverBundle.isBundle(context, legacy)) + assertTrue(DriverBundle.isBundle(context, bundle())) + } + + @Test fun installsBothHalvesAndPicksAndDeletesThemTogether() { + val b = DriverBundle.install(context, bundle()) + assertEquals("DD-Turnip-v0.1.0", b.id) + assertEquals("DD-Turnip 0.1.0", b.label) + assertTrue(TurnipDriver(context).isInstalled(b.androidId)) + assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId)) + assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id) + assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id) + + assertNull(DriverBundle.active(context)) + DriverBundle.select(context, b) + assertEquals(b.androidId, SessionPrefs.androidDriver(context)) + assertEquals(b.linuxId, SessionPrefs.linuxDriver(context)) + assertNotNull(DriverBundle.active(context)) + + DriverBundle.remove(context, b) + assertFalse(TurnipDriver(context).isInstalled(b.androidId)) + assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId)) + assertEquals("", SessionPrefs.androidDriver(context)) + assertEquals("", SessionPrefs.linuxDriver(context)) + assertTrue(DriverBundle.all(context).isEmpty()) + } + + @Test fun aRefusedHalfLeavesNothingInstalled() { + try { + DriverBundle.install(context, bundle(linuxLibc = "libc.so")) + fail("a bionic library in linux/ must be refused") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("glibc")) + } + assertTrue(TurnipDriver(context).enumerateImported().isEmpty()) + assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty()) + } + + @Test fun entriesOutsideTheBundleAreRefused() { + try { + DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4)))) + fail("a zip-slip entry must be refused") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("outside")) + } + assertFalse(File(context.cacheDir.parentFile, "escape.so").exists()) + } + + @Test fun twoInstallsOfOneReleaseGetTheirOwnIds() { + val first = DriverBundle.install(context, bundle()) + val second = DriverBundle.install(context, bundle()) + assertEquals("DD-Turnip-v0.1.0-2", second.id) + assertEquals(2, DriverBundle.all(context).size) + assertFalse(first.androidId == second.androidId) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt new file mode 100644 index 000000000..47431f253 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt @@ -0,0 +1,109 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriverPairsTest { + private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) = + GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi) + + private fun asset(name: String, tag: String, linux: Boolean, pair: String) = + TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair) + + @Test + fun familiesFollowTheAdrenoModel() { + assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825)) + assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810)) + assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722)) + assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740)) + assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610)) + assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0)) + assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0)) + } + + @Test + fun onlyTestedHardwareCountsAsSupported() { + assertEquals(GpuInfo.Support.TESTED, gpu(740).support) + assertEquals(GpuInfo.Support.TESTED, gpu(825).support) + assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support) + assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support) + assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support) + } + + @Test + fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() { + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList())) + assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList())) + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList())) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList())) + assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList())) + assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList())) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList())) + assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList())) + } + + @Test + fun pairsMatchHalvesAndNeverMixBannersReleases() { + val check = TurnipReleases.Check( + listOf( + asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), + asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER), + // The A8xx set's newest display build is r4, but its Linux half is only in r3. + asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX), + asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX), + asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED), + asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED), + ), + emptyList(), emptyList(), 0L, + ) + val pairs = DriverPairs.from(check).associateBy { it.key } + assertTrue(pairs.getValue(DriverPairs.BANNER).complete) + assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete) + val wn = pairs.getValue(DriverPairs.WN_BALANCED) + assertTrue("WinNative's two release lines pair up", wn.complete) + assertEquals("v1.19 + v0.1.2", wn.version) + assertTrue(wn.suits(gpu(830))) + assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830))) + } + + @Test + fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() { + fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + val newer = bundle("DD-Turnip-v0.2.0") + val older = bundle("DD-Turnip-v0.1.0") + val pairs = DriverPairs.from(TurnipReleases.Check( + listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older, + asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)), + emptyList(), emptyList(), 0L, + )) + assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key }) + val dd = pairs.first() + assertTrue(dd.complete) + assertEquals(listOf(newer), dd.assets) + assertEquals("v0.2.0", dd.version) + assertEquals("DroidDeck · DD-Turnip", dd.name) + assertTrue(dd.suits(gpu(840))) + assertTrue(dd.suits(gpu(740))) + } + + @Test + fun adreno840GetsTheNewestOfficialDdTurnip() { + fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + val pairs = DriverPairs.from(TurnipReleases.Check( + listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"), + asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED), + asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)), + emptyList(), emptyList(), 0L, + )) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs)) + assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs)) + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs)) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs)) + } +} 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/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt index 2ac5f5e80..6e57f3d67 100644 --- a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt @@ -20,7 +20,7 @@ class AppUpdatesTest { checkedAt = 0L, ) - private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, ci = true) + private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, updatable = true) @Test fun theStableBuildIsCurrentOnStable() { diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md index 4639afa5b..4c4efc0e1 100644 --- a/docs/development/steam-game-imports.md +++ b/docs/development/steam-game-imports.md @@ -21,16 +21,21 @@ The resulting snapshot is stored under that account's `userdata//config` and refreshed every 30 seconds while logged in. Title lookups retry every five minutes and when the source changes, so an early network failure does not end the watcher. Changed status and probe failures are written to the session log. Startup and post-exit import -use the last successful snapshot for the selected account. A newly identified title -therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts -again. An unavailable client or offline first launch falls back to shortcuts; existing +use the last successful snapshot for the selected account. The client reads manifests +and shortcuts only at start, so when a snapshot confirms owned titles that are not yet +imported, the watcher restarts the client once (not while a game runs or a download is in +progress) and the importer runs between the exit and the next start. An unavailable client or offline first launch falls back to shortcuts; existing snapshots remain usable offline. Steam still enforces the current license at launch. For a confirmed game without a manifest, the importer links the source folder into the internal Steam library and writes an installed manifest with `Universe=1` and -`StateFlags=4`. Steam ignores manifests without the universe field. This registers -existing files; it does not assert a verified build ID or depot manifest. Steam can -still require verification, updates or redistributables. Steam updates and uninstall +`StateFlags=4`. Steam ignores manifests without the universe field. A manifest without +installed depots makes Steam verify every file, so the importer carries the build another +client recorded: WinNative's `.winnative-steam/steamapps` manifest beside the game folder, +or a completed download's `.DepotDownloader/depot.config` depot manifest IDs. Steam then +only downloads what changed since that build. Bare manifests from earlier imports are +upgraded while Steam has not touched them. Without either record Steam still verifies. +WinNative's manifest also identifies the folder without a store lookup. Steam updates and uninstall operate on the linked game files, so imported folders must be writable. Existing Steam manifests and directories are preserved. Only the exact minimal manifests generated by the earlier importer are migrated, with the matching source symlink still present. If diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games index 16fc736ec..7690fd219 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games @@ -11,7 +11,7 @@ import sys HOME = os.environ.get("HOME", "/root") STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam") -LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/bannerlator-sd", "SD Games")) +LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games")) # The client's own tools and runtimes live in steamapps beside the games; they are not titles. NOT_GAMES = {228980, 1493710, 3127680, 4183110, 4427310, 4185400} APPS_DIR = os.path.join(HOME, ".local/share/applications") diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists index 9d2d60fa7..ea1d413f5 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 \ - /mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do +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/droiddeck-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..01fb68198 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -234,7 +234,7 @@ case $mode in bannerlator-steam-install || exit $? step "Steam client ready" steam_root=$XDG_DATA_HOME/Steam - # Register the card library (bound at /mnt/bannerlator-sd) with the client - internal storage is + # Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is # its main library already - and the ARM64 Proton that lets it launch the Windows ones. # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts, # so it is in the Compatibility dropdown the first time the user looks. One per line, each the @@ -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/droiddeck-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..46dcdb83e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -13,6 +13,9 @@ import urllib.parse import urllib.request STATE = ".droiddeck-games.json" +WINNATIVE = ".winnative-steam/steamapps" +LIBRARY = "/mnt/droiddeck-sd" +LEGACY_LIBRARY = "/mnt/bannerlator-sd" BASE_ID = 76561197960265728 @@ -40,6 +43,11 @@ def edition_key(name): return title_key(name) +def legacy_path(path): + path = str(path) + return LEGACY_LIBRARY + path[len(LIBRARY):] if path == LIBRARY or path.startswith(LIBRARY + "/") else path + + def source_stamp(game): try: exe = Path(game["exe"]) @@ -53,19 +61,23 @@ def source_stamp(game): return None +def manifest_ids(steamapps, name): + matches = [] + for manifest in Path(steamapps).glob("appmanifest_*.acf"): + try: + install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I) + if install and install[1] == name: + matches.append(int(manifest.stem.removeprefix("appmanifest_"))) + except (OSError, ValueError): + pass + return matches + + def identify(game): folder = Path(game["folder"]) steamapps = folder.parent.parent - if folder.parent.name == "common" and steamapps.name == "steamapps": - matches = [] - for manifest in steamapps.glob("appmanifest_*.acf"): - try: - text = manifest.read_text() - install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I) - if install and install[1] == folder.name: - matches.append(int(manifest.stem.removeprefix("appmanifest_"))) - except (OSError, ValueError): - pass + for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]: + matches = manifest_ids(library, folder.name) if matches: return matches[0] if len(matches) == 1 else None ids = set() @@ -128,6 +140,43 @@ 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 downloads_idle(steam_root): + try: + return not any(Path(steam_root, "steamapps/downloading").iterdir()) + except OSError: + return True + + +def request_restart(steam_root): + """The client reads manifests and shortcuts only at start, so an import needs one restart; + the session's loop runs the importer between the exit and the next start.""" + Path(os.environ.get("HOME", "/root"), ".bl-restart-once").touch() + subprocess.run([str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) + + +def unimported(steam_root, snapshot, candidates): + routes = read_json(Path(steam_root) / "userdata" / snapshot["account"] / "config/.droiddeck-routes.json", {}) + routed = set(routes.values()) if isinstance(routes, dict) else set() + return sorted({appid for appid in candidates.values() if snapshot["owned"].get(str(appid)) is True} - routed) + + def watch(steam_root, listing): games = read_json(listing, []) if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: @@ -138,7 +187,11 @@ 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 + pending, requested = [], [] while True: now = time.monotonic() for game in games: @@ -155,7 +208,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 +218,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: @@ -172,18 +229,59 @@ def watch(steam_root, listing): snapshot["candidates"] = candidates snapshot["sources"] = sources save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot) + pending = unimported(steam_root, snapshot, candidates) 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 pending and pending != requested and not game_running() and downloads_idle(steam_root): + requested = pending + print("bannerlator-steam-games: restarting the client once to import %s" % pending, flush=True) + try: + request_restart(steam_root) + except (OSError, subprocess.TimeoutExpired) as error: + print("bannerlator-steam-games: restart unavailable: %s" % error, flush=True) if status != last_status: print("bannerlator-steam-games: " + status, flush=True) last_status = status time.sleep(30) +def installed_build(folder, appid, installdir): + """The build another Steam client recorded for these files, so Steam updates from it + instead of verifying every file.""" + try: + text = (folder.parent / WINNATIVE / f"appmanifest_{appid}.acf").read_text() + install = re.search(r'("installdir"\s*")([^"\\]*)(")', text, re.I) + flags = re.search(r'"StateFlags"\s*"(\d+)"', text) + if (install and install[2] == folder.name and flags and flags[1] == "4" + and re.search(rf'"appid"\s*"{appid}"', text) and '"InstalledDepots"' in text): + return text[:install.start(2)] + installdir + text[install.end(2):] + except OSError: + pass + if not (folder / ".download_complete").is_file(): + return None + try: + ids = json.loads((folder / ".DepotDownloader/depot.config").read_text())["installedManifestIDs"] + depots = sorted((int(depot), int(gid)) for depot, gid in ids.items()) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return None + if not depots or not all(0 < depot < 0x80000000 and 0 < gid < 0x7FFFFFFFFFFFFFFF for depot, gid in depots): + return None + return "".join(f' "{depot}"\n {{\n "manifest" "{gid}"\n }}\n' for depot, gid in depots) + + +def update_not_started(text, steamapps, appid, installdir): + fields = dict(re.findall(r'^\s*"(\w+)"\s*"([^"]*)"', text, re.M)) + return (fields.get("installdir") == installdir and fields.get("buildid", "0") == "0" + and fields.get("BytesDownloaded", "0") == "0" and fields.get("BytesStaged", "0") == "0" + and re.search(r'"InstalledDepots"\s*\{\s*\}', text) is not None + and not (steamapps / "downloading" / str(appid)).exists()) + + def seed_install(steam_root, game, appid): steamapps = Path(steam_root) / "steamapps" common = steamapps / "common" @@ -191,16 +289,30 @@ def seed_install(steam_root, game, appid): target = common / name source = Path(game["folder"]) title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ") - content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n' + minimal = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n' + f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n') - legacy = content.replace(' "Universe" "1"\n', '') + legacy = minimal.replace(' "Universe" "1"\n', '') + build = installed_build(source, appid, name) + if build is None: + content = minimal + elif build.lstrip().startswith('"AppState"'): + content = build + else: + content = minimal[:-2] + ' "InstalledDepots"\n {\n' + build + ' }\n}\n' manifest = steamapps / f"appmanifest_{appid}.acf" - for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]: + if target.is_symlink() and os.readlink(target) != str(source) and os.readlink(target) == legacy_path(source): + relink = target.with_name(name + ".droiddeck") + relink.unlink(missing_ok=True) + relink.symlink_to(source, target_is_directory=True) + os.replace(relink, target) + for root in [Path(steam_root), Path(LIBRARY)]: existing = root / "steamapps" / manifest.name if existing.is_file(): if (existing == manifest and source.is_dir() and target.is_symlink() and os.readlink(target) == str(source) - and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))): + and existing.read_text() != content + and (existing.read_text() in (minimal, legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"')) + or (build is not None and update_not_started(existing.read_text(), steamapps, appid, name)))): temp = manifest.with_suffix(".droiddeck") temp.write_text(content) os.replace(temp, manifest) @@ -232,11 +344,13 @@ def route(steam_root, acct, games): owned, candidates, sources = {}, {}, {} shortcuts, routes = [], {} for game in games: - appid = candidates.get(game.get("folder")) + folder = game.get("folder", "") + appid = candidates.get(folder, candidates.get(legacy_path(folder))) stamp = source_stamp(game) + recorded = sources.get(folder, sources.get(legacy_path(folder))) confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000 and owned.get(str(appid)) is True and stamp is not None - and sources.get(game.get("folder")) == stamp) + and recorded in (stamp, [stamp[0], legacy_path(stamp[1])] + stamp[2:])) if confirmed: try: if seed_install(steam_root, game, appid): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library index e67878e6e..d065db6b5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library @@ -1,5 +1,5 @@ #!/usr/bin/python3 -# Registers the card the app installs to (bound at /mnt/bannerlator-sd) as a library folder of the +# Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the # native Steam client, so they show as installed there instead of being downloaded a second time. # # bannerlator-steam-library @@ -14,7 +14,7 @@ import sys # The app binds the storage the user chose (Steam cog -> Game storage) at this path and names it # in BL_LIBRARY_LABEL; nothing bound = internal only, and the entry is removed so the client does # not keep offering a place that is not there. -LIBRARIES = (("/mnt/bannerlator-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),) +LIBRARIES = (("/mnt/droiddeck-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),) # The client calls its own library "Local Drive (/)", which says nothing about where that is. # It is the phone's internal storage, so say so. MAIN_LABEL = "Internal Storage" @@ -66,6 +66,26 @@ def register(steam_root, library, label): RETIRED = ("/mnt/bannerlator",) +RENAMED = (("/mnt/bannerlator-sd", "/mnt/droiddeck-sd"),) + + +def rename(steam_root, old, new): + """Moves a library registered under an earlier mount point to the current one, keeping its + entry (content id, app sizes) rather than registering it anew.""" + path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf") + if not os.path.isfile(path): + return + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + old_path = re.compile(r'("path"\s+")%s(")' % re.escape(old)) + if not old_path.search(text): + return + if re.search(r'"path"\s+"%s"' % re.escape(new), text): + forget(steam_root, old) + return + with open(path, "w", encoding="utf-8") as f: + f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text)) + print("bannerlator-steam-library: moved %s to %s" % (old, new)) def forget(steam_root, library): @@ -152,6 +172,8 @@ def main(): steam_root = sys.argv[1] for library in RETIRED: forget(steam_root, library) + for old, new in RENAMED: + rename(steam_root, old, new) relabel(steam_root, steam_root, MAIN_LABEL) for library, label in LIBRARIES: if os.path.isdir(os.path.join(library, "steamapps")): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts index c3fcd4ae3..0abb8b719 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts @@ -97,7 +97,7 @@ def write(obj): return bytes(out) -def shortcut(game): +def shortcut(game, launch_options=""): appid = int(game["appid"]) & 0xFFFFFFFF return { "appid": struct.unpack(" +#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; diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 26220319d..34c96c5d6 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -20,6 +20,7 @@ def load(name): imports = load('bannerlator-steam-games') shortcuts = load('bannerlator-steam-shortcuts') +library = load('bannerlator-steam-library') class ImportsTest(unittest.TestCase): @@ -73,6 +74,7 @@ class ImportsTest(unittest.TestCase): from unittest.mock import Mock result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True}))) with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \ + patch.object(imports, 'request_restart'), \ patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \ patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \ patch.object(imports.subprocess, 'run', return_value=result), \ @@ -83,6 +85,35 @@ class ImportsTest(unittest.TestCase): snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {}) self.assertTrue(snapshot['owned']['42']) + def run_watch(self, beats, routes=None, busy=False): + listing = self.root / 'games.json' + listing.write_text(json.dumps([self.game])) + if routes is not None: + imports.save_json(self.acct / 'config/.droiddeck-routes.json', routes) + if busy: + (self.steam / 'steamapps/downloading/7').mkdir(parents=True) + from unittest.mock import Mock + result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True}))) + with patch.object(imports, 'identify', return_value=42), \ + patch.object(imports, 'game_running', return_value=False), \ + patch.object(imports, 'request_restart') as restart, \ + patch.object(imports.time, 'monotonic', side_effect=list(range(0, 30 * beats, 30))), \ + patch.object(imports.time, 'sleep', side_effect=[None] * (beats - 1) + [RuntimeError('stop')]), \ + patch.object(imports.subprocess, 'run', return_value=result), \ + patch('builtins.print'): + with self.assertRaisesRegex(RuntimeError, 'stop'): + imports.watch(self.steam, listing) + return restart + + def test_newly_owned_game_restarts_the_client_once(self): + self.assertEqual(1, self.run_watch(3).call_count) + + def test_imported_game_does_not_restart_the_client(self): + self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count) + + def test_restart_waits_for_downloads(self): + self.assertEqual(0, self.run_watch(2, busy=True).call_count) + def test_unowned_unknown_and_different_account_stay_shortcuts(self): for owned, account in [(False, '123'), (True, '456')]: self.snapshot(owned, account) @@ -91,6 +122,93 @@ class ImportsTest(unittest.TestCase): self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) self.assertFalse((self.steam / 'steamapps').exists()) + def winnative_manifest(self, flags='4', installdir='Example'): + path = self.folder.parent / imports.WINNATIVE / 'appmanifest_42.acf' + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n' + '\t"buildid"\t\t"7"\n\t"InstalledDepots"\n\t{\n\t\t"43"\n\t\t{\n\t\t\t"manifest"\t\t"99"\n' + '\t\t\t"size"\t\t"4"\n\t\t}\n\t}\n}\n' % (flags, installdir)) + return path + + def test_winnative_manifest_carries_installed_build(self): + source = self.winnative_manifest() + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + text = (self.steam / 'steamapps/appmanifest_42.acf').read_text() + self.assertEqual(source.read_text().replace('"Example"', '"DroidDeck-42"'), text) + + def test_winnative_manifest_identifies_folder(self): + self.winnative_manifest() + with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError): + self.assertEqual(42, imports.identify(self.game)) + + def test_incomplete_winnative_manifest_is_ignored(self): + for flags, installdir in [('1026', 'Example'), ('4', 'Other')]: + self.winnative_manifest(flags, installdir) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + + def test_depot_config_records_installed_depots(self): + config = self.folder / '.DepotDownloader/depot.config' + config.parent.mkdir() + config.write_text(json.dumps({'installedManifestIDs': {'43': 99}})) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + (self.folder / '.download_complete').touch() + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + text = (self.steam / 'steamapps/appmanifest_42.acf').read_text() + self.assertIn('"InstalledDepots"\n {\n "43"\n {\n "manifest" "99"', text) + self.assertTrue(text.endswith(' }\n}\n')) + config.write_text(json.dumps({'installedManifestIDs': {'43': 0x7FFFFFFFFFFFFFFF}})) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + + def test_bare_import_manifest_gains_installed_build(self): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + self.winnative_manifest() + imports.route(self.steam, self.acct, [self.game]) + self.assertIn('"buildid"\t\t"7"', (self.steam / 'steamapps/appmanifest_42.acf').read_text()) + + def steam_fresh_install(self, downloaded='0'): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + path = self.steam / 'steamapps/appmanifest_42.acf' + path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n' + '\t"buildid"\t\t"0"\n\t"BytesDownloaded"\t\t"%s"\n\t"BytesStaged"\t\t"0"\n' + '\t"InstalledDepots"\n\t{\n\t}\n}\n' % downloaded) + self.winnative_manifest() + return path + + def test_queued_fresh_install_takes_installed_build(self): + path = self.steam_fresh_install() + imports.route(self.steam, self.acct, [self.game]) + self.assertIn('"buildid"\t\t"7"', path.read_text()) + + def test_started_fresh_install_is_left_to_steam(self): + for downloaded, staging in [('5', False), ('0', True)]: + path = self.steam_fresh_install(downloaded) + if staging: + (self.steam / 'steamapps/downloading/42').mkdir(parents=True) + before = path.read_text() + imports.route(self.steam, self.acct, [self.game]) + self.assertEqual(before, path.read_text()) + + def test_links_and_snapshots_from_the_old_mount_carry_over(self): + old = self.root / 'old-mount' + with patch.object(imports, 'LIBRARY', str(self.root / 'Games')), \ + patch.object(imports, 'LEGACY_LIBRARY', str(old)): + legacy_game = dict(self.game, folder=str(old / 'Example'), exe=str(old / 'Example/Example.exe')) + stamp = imports.source_stamp(self.game) + imports.save_json(self.acct / 'config' / imports.STATE, + dict(account='123', owned={'42': True}, candidates={legacy_game['folder']: 42}, + sources={legacy_game['folder']: [stamp[0], legacy_game['exe']] + stamp[2:]})) + link = self.steam / 'steamapps/common/DroidDeck-42' + link.parent.mkdir(parents=True) + link.symlink_to(legacy_game['folder'], target_is_directory=True) + games, routes = imports.route(self.steam, self.acct, [self.game]) + self.assertEqual([], games) + self.assertEqual({str(self.game['appid']): 42}, routes) + self.assertEqual(str(self.folder), imports.os.readlink(link)) + def test_existing_manifest_is_never_overwritten(self): manifest = self.steam / 'steamapps/appmanifest_42.acf' manifest.parent.mkdir(parents=True) @@ -188,6 +306,34 @@ class ImportsTest(unittest.TestCase): self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text())) +class LibraryRenameTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.steam = Path(temp.name) + self.vdf = self.steam / 'steamapps/libraryfolders.vdf' + self.vdf.parent.mkdir() + + def entry(self, index, path): + return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"contentid"\t\t"%d"\n\t}\n' % (index, path, 7 + index) + + def test_old_mount_keeps_its_entry_under_the_new_path(self): + self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/root') + self.entry(1, '/mnt/bannerlator-sd') + '}\n') + with patch('builtins.print'): + library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd') + text = self.vdf.read_text() + self.assertIn('"path"\t\t"/mnt/droiddeck-sd"\n\t\t"contentid"\t\t"8"', text) + self.assertNotIn('bannerlator-sd', text) + + def test_old_mount_is_dropped_when_new_path_is_registered(self): + self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/mnt/droiddeck-sd') + self.entry(1, '/mnt/bannerlator-sd') + '}\n') + with patch('builtins.print'): + library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd') + text = self.vdf.read_text() + self.assertEqual(1, text.count('"path"')) + self.assertIn('/mnt/droiddeck-sd', text) + + class SteamProbeTest(unittest.TestCase): def test_probe_reads_account_and_licenses_and_releases_handles(self): import ctypes as C