Merge remote-tracking branch 'origin/main' into fix/pr103-conflicts

# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/App.kt
#	app/src/main/java/com/droiddeck/launcher/MainActivity.kt
#	app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
This commit is contained in:
maxjivi05 committed 2026-09-30 16:56:59 -04:00
commit b31ab5fa96
78 files changed
+1754 -508

No files matched your search

+1 -1
View File
@@ -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.
@@ -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"
@@ -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
+3 -4
View File
@@ -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"
+3 -4
View File
@@ -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
@@ -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"
@@ -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
@@ -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"
@@ -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
@@ -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"
@@ -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
+3 -33
View File
@@ -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{};
+4 -11
View File
@@ -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 <cstdint>
@@ -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);
+4 -3
View File
@@ -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"
@@ -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,
+7 -2
View File
@@ -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 <cstdint>
@@ -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<LsfgShaders> shaders_;
@@ -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"
@@ -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
@@ -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"
@@ -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
@@ -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;
@@ -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"
@@ -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
@@ -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.
@@ -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
@@ -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"
@@ -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
+2 -2
View File
@@ -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
@@ -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) {
@@ -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" : "");
}
}
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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
@@ -6,6 +6,7 @@
*/
#include <jni.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <pthread.h>
#include <stdlib.h>
@@ -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;
}
@@ -3,10 +3,12 @@ package com.droiddeck.launcher
import android.app.Application
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.session.CrashHandler
import com.droiddeck.launcher.session.GpuClockPin
/**
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
* the socket Flatpak's sandboxes are started through (BwrapSpawner).
* the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's
* sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun onCreate() {
@@ -15,6 +17,7 @@ class App : Application() {
// Before anything else touches the session prefs: a new install's first read decides it.
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.Manifest
import android.app.ActivityOptions
import android.content.Intent
@@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
@@ -100,6 +101,7 @@ class MainActivity : ComponentActivity() {
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
@@ -133,6 +135,7 @@ class MainActivity : ComponentActivity() {
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -160,6 +163,9 @@ class MainActivity : ComponentActivity() {
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
@@ -169,6 +175,26 @@ class MainActivity : ComponentActivity() {
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
ui.post { lossless = state }
}, "lossless-sync").start()
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
val state = Lossless.state(this)
ui.post {
lossless = state
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "lossless-import").start()
}
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
@@ -238,6 +264,7 @@ class MainActivity : ComponentActivity() {
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
@@ -320,6 +347,7 @@ class MainActivity : ComponentActivity() {
updates.start()
setContent {
DroidDeckTheme(theme) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -338,8 +366,8 @@ class MainActivity : ComponentActivity() {
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
@@ -415,10 +443,13 @@ class MainActivity : ComponentActivity() {
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this, engine, multiplier)
onFrameGenPick = { mode ->
FrameGen.set(this, mode)
frameGenLabel = FrameGen.label(this)
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
@@ -569,6 +600,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
@@ -649,6 +681,7 @@ class MainActivity : ComponentActivity() {
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
syncLossless()
// Opening the app and coming back from a session both land here.
updates.onResume()
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
@@ -905,7 +938,7 @@ class MainActivity : ComponentActivity() {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
@@ -938,6 +971,7 @@ class MainActivity : ComponentActivity() {
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -1003,6 +1037,7 @@ class MainActivity : ComponentActivity() {
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
@@ -1011,6 +1046,7 @@ class MainActivity : ComponentActivity() {
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -1063,6 +1099,7 @@ class MainActivity : ComponentActivity() {
refreshAddedGames()
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
@@ -1108,6 +1145,7 @@ class MainActivity : ComponentActivity() {
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -1,5 +1,6 @@
package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
@@ -23,6 +24,7 @@ import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -32,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.KeyboardHost
@@ -169,9 +172,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
private var frameGenMultiplier by mutableStateOf(2)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
@@ -186,7 +191,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) {
followPanelRefresh(displayId)
refreshSecondScreenDisplays()
}
}
/**
* The panel's rate, followed for the rest of the session. It is read once at start, and a
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
*/
private fun followPanelRefresh(displayId: Int) {
if (!CompositorHost.isStarted) return
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
if (display.displayId != displayId) return
val hz = display.refreshRate
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
}
private var isHomeApp by mutableStateOf(false)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
@@ -215,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: the panel's fastest mode, and the OS told
// it is in gameplay. Logged so a slow device says why.
Log.i(TAG, "perf: " + PerfMode.apply(this))
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
// says why. The cap is the session's: an activity re-created mid-session keeps it.
if (!SessionState.running) {
SessionState.fpsLimit = SessionPrefs.fpsLimit(
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
)
}
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
// PulseAudio are media playback); they are never forwarded to the guest.
@@ -315,6 +343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
@@ -336,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
@@ -359,8 +389,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
@@ -369,11 +399,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
applyFrameGen()
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
@@ -445,6 +479,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
}
}
private fun refreshHomeApp() {
@@ -578,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
touchMode = SessionPrefs.touchMode(this)
frameGenLabel = FrameGen.label(this)
frameGenEngine = FrameGen.engine(this)
frameGenMultiplier = FrameGen.multiplier(this)
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
@@ -775,7 +808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
)
if (!SessionState.running) SessionEvents.record("compositor.started")
// The service owns everything below the compositor. It is started whenever no session is
@@ -884,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val hz = refreshHz()
Thread({
val problem = FrameGen.apply(this, hz)
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
val state = Lossless.state(this)
runOnUiThread {
lossless = state
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
}
}, "frame-gen").start()
}
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
runOnUiThread { lossless = state }
}, "lossless-sync").start()
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
val state = Lossless.state(this)
runOnUiThread {
lossless = state
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
}
}, "lossless-import").start()
}
// ── Session state ───────────────────────────────────────────────────────────────────────
/** Keeps the loading overlay current from the session log, and its clock moving. */
@@ -1256,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
*/
private fun syncClientCursor(): Boolean {
if (!CompositorHost.isStarted) return false
// Asked on every pointer move; the image is copied only when the client has changed it.
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
if (n < 6) return false
val serial = cursorBuf[0]
@@ -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"
@@ -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)
}
@@ -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.
*
* <p>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.
*
* <p>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);
}
@@ -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()
}
}
@@ -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))
}
}
@@ -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<String>(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
}
}
@@ -283,6 +283,13 @@ object SessionPrefs {
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
* but correct. Max's advice for devices whose kernels "don't work well with it".
*/
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
fun setGpuClockPin(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
fun setProotNoSeccomp(context: Context, on: Boolean) {
@@ -556,6 +563,22 @@ object SessionPrefs {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
/**
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
* -r (what the client and its games see as the display's rate), the compositor's buffer release
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
* does. Applies next session.
*/
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
fun setFpsLimit(context: Context, mode: String, fps: Int) {
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
}
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/**
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
@@ -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<String>()
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
@@ -287,7 +290,7 @@ class SessionService : Service() {
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=0")
guest.add("BL_FPS=" + SessionState.fpsLimit)
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -314,6 +317,7 @@ class SessionService : Service() {
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
@@ -435,6 +439,7 @@ class SessionService : Service() {
}
sessionPid = pid
if (pid > 1) {
raiseTracer(pid, gen)
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
@@ -524,6 +529,12 @@ class SessionService : Service() {
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
// file cache is an open and every store an open and a rename, each a proot stop (a rename
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
// the old folder itself once it has gone a week untouched.
guest.add("MESA_DISK_CACHE_DATABASE=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
@@ -964,6 +975,31 @@ class SessionService : Service() {
}
}
/**
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
* proot itself and would inherit the value, and with it everything the session starts.
*/
private fun raiseTracer(pid: Int, gen: Int) {
Thread({
try {
Thread.sleep(2000)
} catch (e: InterruptedException) {
return@Thread
}
if (gen != sessionGen || sessionPid != pid) return@Thread
val nice = try {
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
} catch (t: Throwable) {
Log.w(TAG, "tracer priority", t)
return@Thread
}
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
}, "tracer-priority").start()
}
private fun stopSession(status: Int) {
synchronized(stopLock) {
if (!SessionState.running) return
@@ -972,6 +1008,7 @@ class SessionService : Service() {
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
launchWatcher?.stopWatching()
@@ -1148,6 +1185,8 @@ class SessionService : Service() {
companion object {
private const val TAG = "SessionService"
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
@@ -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
@@ -346,7 +346,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
.glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
@@ -369,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
@@ -411,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
.glideBorder(hot, ROW_SHAPE, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
@@ -440,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
.glideBorder(delHot, ROW_SHAPE, colors.error)
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
@@ -92,7 +92,7 @@ fun DriverPage(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp))
.glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line)
.focusRequester(refreshFocus)
.hoverable(refreshSrc)
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
@@ -174,7 +174,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
.glideBorder(hot, shape, pal.signal),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -210,7 +210,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp))
.glideBorder(delHot, RoundedCornerShape(10.dp), colors.error)
.focusRequester(delFocus)
.focusProperties { left = mainFocus }
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
@@ -230,7 +230,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color,
modifier = modifier.clip(shape)
.background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (hot) color else Color.Transparent, shape)
.glideBorder(hot, shape, color)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
)
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.ui
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.abs
// One focus ring for a whole screen that slides from control to control. When focus moves, the
// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so
// the ring stretches like a drop and snaps into the new control's shape.
/** A control that can show the ring: where it is and what its ring looks like there. */
internal class GlideSource {
var coords: LayoutCoordinates? = null
var shape: Shape by mutableStateOf<Shape>(androidx.compose.ui.graphics.RectangleShape)
var color by mutableStateOf(Color.Transparent)
var width by mutableStateOf(2.dp)
}
internal class FocusGlide(val view: View) {
var host: LayoutCoordinates? = null
/** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */
val hot = mutableStateListOf<GlideSource>()
/** Bumped when the current control moves (a scroll, a relayout). */
var moves by mutableIntStateOf(0)
val current: GlideSource? get() = hot.lastOrNull()
fun claim(s: GlideSource) { hot.remove(s); hot.add(s) }
fun release(s: GlideSource) { hot.remove(s) }
fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ }
/** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */
fun boundsOf(s: GlideSource): Rect? {
val h = host ?: return null
val c = s.coords ?: return null
if (!h.isAttached || !c.isAttached) return null
val b = h.localBoundingBoxOf(c, clipBounds = true)
return if (b.width < 1f || b.height < 1f) null else b
}
}
internal val LocalFocusGlide = staticCompositionLocalOf<FocusGlide?> { null }
private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float =
when (val o = shape.createOutline(size, dir, density)) {
is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x
is Outline.Rectangle -> 0f
is Outline.Generic -> minOf(size.width, size.height) / 2f
}
/**
* A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot].
* Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a
* popup, a screen with no host) it is drawn here, as a plain border.
*/
@Composable
internal fun Modifier.glideBorder(
hot: Boolean,
shape: Shape,
hotColor: Color,
restColor: Color = Color.Transparent,
hotWidth: Dp = 2.dp,
restWidth: Dp = 1.dp,
): Modifier {
val glide = LocalFocusGlide.current
if (glide == null || glide.view !== LocalView.current) {
return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape)
}
val src = remember { GlideSource() }
src.shape = shape
src.color = hotColor
src.width = hotWidth
DisposableEffect(glide, hot) {
if (hot) glide.claim(src)
onDispose { glide.release(src) }
}
val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this
return base.onGloballyPositioned { src.coords = it; glide.moved(src) }
}
/** Leading edge on a focus move: quick and a little loose. */
private fun lead(): AnimationSpec<Float> = Motion.sp(0.62f, 700f)
/** Trailing edge: slower, settles without a bounce to speak of. */
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
private const val SETTLE_MS = 450L
/**
* Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the
* host's space, so put the host inside anything that moves as a whole (a sheet that slides in).
*/
@Composable
internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val view = LocalView.current
val density = LocalDensity.current
val dir = LocalLayoutDirection.current
val glide = remember(view) { FocusGlide(view) }
val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom
val corner = remember { Animatable(0f) }
val alpha = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val jobs = remember { arrayOfNulls<Job>(5) }
val cur = glide.current
var lastColor by remember { mutableStateOf(Color.Transparent) }
if (cur != null) lastColor = cur.color
val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor")
var width by remember { mutableStateOf(2.dp) }
if (cur != null) width = cur.width
LaunchedEffect(glide) {
var shown: GlideSource? = null
var movedAt = 0L
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
var delays = LongArray(4)
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
jobs[i]?.cancel()
jobs[i] = scope.launch {
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
edges[i].animateTo(v, spec)
}
}
fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) {
val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size
val r = cornerOf(s.shape, size, dir, density)
jobs[4]?.cancel()
jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) }
}
snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) ->
if (src == null) {
// Focus passing between two controls can let go of one a moment before the next
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
delay(60)
scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
shown = null
return@collectLatest
}
var first = src !== shown
val start = System.nanoTime()
var last: Rect? = null
while (true) {
val b = glide.boundsOf(src)
if (b == null) {
if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
} else if (first) {
val prev = shown
val wasShowing = prev != null && alpha.targetValue > 0f
shown = src
first = false
if (!wasShowing) {
// Appearing: a hair bigger, pulled in onto the control as it fades up.
val out = with(density) { 3.dp.toPx() }
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
jobs.forEach { it?.cancel() }
scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
delays = LongArray(4)
cornerTo(src, b, snap = true)
scope.launch { alpha.animateTo(1f, Motion.tw(140)) }
} else {
// Moving: the edges on the side it is heading lead.
val dx = b.center.x - (edges[0].value + edges[2].value) / 2f
val dy = b.center.y - (edges[1].value + edges[3].value) / 2f
val horizontal = abs(dx) >= abs(dy)
val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1)
val trailIdx = (leadIdx + 2) % 4
dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L }
cornerTo(src, b, snap = false)
scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
}
movedAt = System.nanoTime()
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) }
last = b
} else if (b != last) {
// It moved under the ring: keep the stretch while the move is fresh, else follow.
val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) }
if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
last = b
}
// Layer animations (a tile growing, a row sliding) move it without a relayout, so
// look again each frame for a moment after anything changes.
if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break
withFrameNanos { }
}
}
}
Box(
modifier
.onGloballyPositioned { glide.host = it }
.drawWithContent {
drawContent()
val a = alpha.value
if (a <= 0.01f) return@drawWithContent
val w = width.toPx()
val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value
val sw = (r - l - w).coerceAtLeast(0f)
val sh = (b - t - w).coerceAtLeast(0f)
val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f)
drawRoundRect(
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
)
},
) {
CompositionLocalProvider(LocalFocusGlide provides glide) { content() }
}
}
@@ -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() }
}
}
}
@@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
val pal = LocalPalette.current
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
Column(
modifier = track
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
.clip(Shape12)
.background(colors.surface)
.border(2.5.dp, ring, Shape12)
.glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp)
.alpha(if (t.dim && !hot) 0.55f else 1f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
@@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
.clip(Shape12)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
.glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
.controllerConfirm(onClick = onLaunch)
.padding(horizontal = 10.dp, vertical = 6.dp),
@@ -201,7 +201,6 @@ private fun RailItem(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
Box(
contentAlignment = Alignment.Center,
@@ -211,7 +210,7 @@ private fun RailItem(
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
.glideBorder(focused, Shape14, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
@@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
.width(if (iconOnly) 52.dp else 80.dp)
.clip(Shape14)
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
.glideBorder(focused, Shape14, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
.semantics { contentDescription = "Resume $name" }
@@ -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<Library.SteamGame>,
val emulators: List<Library.Emulator>,
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,
@@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
@@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.R
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
@@ -279,7 +281,7 @@ internal fun SetupPanel(
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow("Frame generation", "Select the frame generation mode") {
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
@@ -380,7 +382,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
@@ -393,7 +395,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
}
/**
@@ -409,7 +411,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
add { m ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
@@ -435,14 +437,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, edge, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
@@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp)
.clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
.glideBorder(hot, shape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 8.dp, end = 16.dp),
@@ -180,7 +180,7 @@ internal fun PrimaryButton(
.shine(hot, 0.45f)
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
@@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
contentAlignment = Alignment.Center,
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit)
val hot = rememberHot(src)
val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12)
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
}
@@ -51,6 +51,8 @@ class ModeSettings(
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
val gpuDrivers: String = "Auto",
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val touchMode: String,
val suspendPolicy: String,
/** Steam only. */
@@ -102,6 +104,7 @@ class ModeSettingsActions(
val onHdr: (Boolean) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
val onFpsLimit: (Int) -> Unit = {},
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onOsc: (String) -> Unit,
@@ -168,6 +171,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
host, "fps", "Frame limit", "Applies next session.",
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
note = "Caps the whole session. The screen switches to a rate the cap divides evenly.",
onPick = a.onFpsLimit,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
@@ -202,12 +202,11 @@ private fun RowScope.KeyCap(
else -> Color.White.copy(alpha = 0.07f)
}, label = "keyFill",
)
val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.weight(weight).fillMaxHeight()
.background(fill, RoundedCornerShape(7.dp))
.border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp))
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f))
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown().consume()
@@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) {
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxHeight()
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp))
.border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp))
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f))
.clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.padding(horizontal = 12.dp),
) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) }
@@ -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",
@@ -1,6 +1,28 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.draw.alpha
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
@@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -93,9 +113,11 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.input.SecondScreenDisplay
@@ -137,9 +159,8 @@ class DrawerActions(
val isHomeApp: Boolean,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
val frameGenEngine: String,
val frameGenMultiplier: Int,
val lsfgReady: Boolean,
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val suspendPolicy: String,
val backActionsInverted: Boolean,
@@ -153,7 +174,8 @@ class DrawerActions(
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
val onHud: (Boolean) -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
/** The Android keyboard (text, turned into key presses). */
val onKeyboard: () -> Unit,
/** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */
@@ -195,6 +217,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
@@ -240,10 +264,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)),
) {
FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) {
Column(
modifier = Modifier
.fillMaxHeight()
.width(sheetWidth)
.fillMaxSize()
.background(pal.background.copy(alpha = 0.97f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.focusGroup()
@@ -255,7 +279,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) {
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold,
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true }
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
}
if (a.onSteamMenu != null && a.onQam != null) {
val qamInteraction = remember { MutableInteractionSource() }
@@ -323,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
SettingsRow("Frame generation", null, highlighted = fgOpen) {
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless)
}
}
}
@@ -437,6 +461,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
} }
}
StopConfirm(
open = confirmStop,
pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } },
controllerActive = controllerActive,
onCancel = { confirmStop = false },
onStop = { onScreen ->
confirmStop = false
val from = a.onStopFrom
if (from != null) from(onScreen) else a.onStop()
},
)
} }
}
}
@@ -457,44 +493,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
onDismiss = { appToChooseDisplay = null },
)
}
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
runCatching { cancelFocus.requestFocus() }
}
}
AlertDialog(
onDismissRequest = cancel,
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
dismissButton = {
TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus)
.controllerConfirm(onClick = cancel)) { Text("Cancel") }
},
)
}
}
@Composable
@@ -524,7 +522,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
.clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape)
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 12.dp, end = 14.dp),
@@ -549,7 +547,7 @@ private fun QuickAction(
val shape = RoundedCornerShape(12.dp)
val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(interactionSource)
.clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onConfirm ?: onClick)
@@ -580,7 +578,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(44.dp).clip(ring)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ring)
.glideBorder(hot, ring, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description },
@@ -639,7 +637,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp))
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
@@ -788,3 +786,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
/** Room between the Stop pill and the box that steps out under it: the pull. */
private val StepPull = 6.dp
private val StepBoxCorner = 16.dp
private val StepFillet = 10.dp
private val StepBoxMaxWidth = 264.dp
/**
* The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge
* and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and
* the inside corner where the pull meets the box is rounded the other way. While the box is still
* no wider than the pill it is just the pill, stretched down.
*/
private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) {
path.reset()
val r = p.right; val tl = p.left; val tt = p.top
val pb = maxOf(bottom, p.bottom)
val l = minOf(left, tl)
val pt = minOf(pullTop, pb)
val sw = tl - l
val h = (pb - pt).coerceAtLeast(0f)
val rt = minOf(p.height / 2f, p.width / 2f)
val rbr = minOf(box, (pb - tt) / 2f)
val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f)
val rpl = minOf(box, sw / 2f, h / 2f)
val f = minOf(fillet, sw / 2f)
val yl = minOf(pt + rpl, pb - rbl)
fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) {
if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false)
}
path.moveTo(tl + rt, tt)
path.lineTo(r - rt, tt)
corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt)
path.lineTo(r, pb - rbr)
corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb)
path.lineTo(l + rbl, pb)
corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl)
path.lineTo(l, yl)
corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt)
path.lineTo(tl - f, pt)
corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f)
path.lineTo(tl, tt + rt)
corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt)
path.close()
}
/**
* Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width
* drops out of it and a wider box steps out to the left under it for the choices, right edges
* flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill,
* width first. [onStop] gets Stop's bounds on screen for the flood.
*/
@Composable
private fun StopConfirm(
open: Boolean,
pill: Rect?,
controllerActive: Boolean,
onCancel: () -> Unit,
onStop: (Rect?) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val left = remember { Animatable(0f) }
val bottom = remember { Animatable(0f) }
val tint = remember { Animatable(0f) }
val dim = remember { Animatable(0f) }
val items = remember { List(3) { Animatable(0f) } }
// Composed from the first open until it has folded back into the pill.
var shown by remember { mutableStateOf(false) }
var grown by remember { mutableStateOf(false) }
var content by remember { mutableStateOf<IntSize?>(null) }
val cancelFocus = remember { FocusRequester() }
var stopFocused by remember { mutableStateOf(false) }
var cancelFocused by remember { mutableStateOf(false) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val view = LocalView.current
if (!open && !shown) return
val pull = with(density) { StepPull.toPx() }
LaunchedEffect(open, pill, content) {
val p = pill ?: return@LaunchedEffect
val c = content ?: return@LaunchedEffect
val boxLeft = (p.right - c.width).coerceAtLeast(0f)
val boxBottom = p.bottom + pull + c.height
if (open) {
shown = true
if (!grown) {
left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f)
items.forEach { it.snapTo(0f) }
grown = true
}
coroutineScope {
launch { dim.animateTo(1f, Motion.tw(260)) }
launch { tint.animateTo(1f, Motion.tw(200)) }
launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) }
launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) }
items.forEachIndexed { i, a ->
launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) }
}
if (controllerActive) launch {
delay(Motion.ms(300).toLong())
withFrameNanos { }
runCatching { cancelFocus.requestFocus() }
}
}
} else if (grown) {
coroutineScope {
items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) }
launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) }
}
grown = false
shown = false
content = null
}
}
val p = pill ?: return
Box(Modifier.fillMaxSize()) {
// The rest of the sheet dims behind it, and a tap there cancels.
Box(
Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel),
)
val path = remember { Path() }
val boxCorner = with(density) { StepBoxCorner.toPx() }
val fillet = with(density) { StepFillet.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val restFill = colors.surface
val hotFill = lerp(colors.surface, colors.error, 0.22f)
Canvas(Modifier.fillMaxSize()) {
if (!grown) return@Canvas
stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet)
drawPath(path, lerp(hotFill, restFill, tint.value))
drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim))
}
// The pill's label rides on top, as the pull's handle.
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) }
.size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() })
.padding(start = 12.dp, end = 14.dp),
) {
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp))
Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
}
val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right)
Column(
modifier = Modifier
.offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) }
.width(with(density) { maxW.toDp() })
.onSizeChanged { if (content != it) content = it }
.controllerBack(onCancel)
.onPreviewKeyEvent { e ->
// Keep a controller in here, as the dialog's own window used to.
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (e.key) {
Key.DirectionUp, Key.DirectionDown -> true
Key.DirectionLeft -> cancelFocused
Key.DirectionRight -> stopFocused
else -> false
}
}
.padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp),
) {
Text(
"Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() },
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
) {
ConfirmChoice(
"Cancel", danger = false, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
onClick = onCancel,
)
ConfirmChoice(
"Stop", danger = true, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
.onFocusChanged { stopFocused = it.isFocused }
.onGloballyPositioned { stopCoords = it },
) {
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
}
}
}
}
}
/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */
@Composable
private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(20.dp)
val accent = if (danger) colors.error else pal.signal
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 40.dp).clip(shape)
.background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent)
.glideBorder(hot, shape, accent)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp),
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground)
}
}
@@ -148,7 +148,7 @@ internal fun TabStrip(
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape)
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pal = LocalPalette.current
val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge")
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
@@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
.border(1.dp, edge, RoundedCornerShape(10.dp))
.glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge")
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
val flip = { onChange(!checked) }
val shape = RoundedCornerShape(99.dp)
@@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
) {
Box(
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
.border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape),
.glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2),
) {
Box(
Modifier.padding(4.dp).size(22.dp)
@@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.paneItem("pill:$label").clip(shape)
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
@@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) {
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}")
.clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
.glideBorder(hot, Shape14, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -213,7 +213,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
Text(
if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more")
.clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.clip(Shape12).glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(horizontal = 6.dp, vertical = 4.dp),
@@ -273,13 +273,12 @@ private fun ChannelCard(
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
// Focus shows at once, as on the Components page: no ripple, no fade.
val edge = if (hot || selected) pal.signal else pal.line
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().paneItem("channel:$title")
.clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14)
.clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line)
.alpha(if (enabled) 1f else 0.55f)
.hoverable(src)
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
@@ -316,7 +315,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
.heightIn(min = 52.dp)
.clip(Shape12)
.background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12)
.glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line)
.hoverable(src)
.clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -381,7 +381,7 @@ internal fun AdbTextField(
.fillMaxWidth()
.height(inputHeight)
.background(colors.surfaceVariant, shape)
.border(1.dp, if (focused) palette.signal else palette.line2, shape)
.glideBorder(focused, shape, palette.signal, palette.line2)
.padding(horizontal = 10.dp),
contentAlignment = Alignment.CenterStart,
) {
@@ -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)
}
@@ -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). */
+22
View File
@@ -19,6 +19,28 @@
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
<string name="frame_gen_hint">Select the frame generation mode</string>
<string name="frame_gen_off">Off</string>
<string name="frame_gen_winfg">Win-FG %1$d×</string>
<string name="frame_gen_lsfg">LSFG %1$d×</string>
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
<string name="lsfg_needs_steam">Needs Lossless Scaling on Steam</string>
<string name="lsfg_needs_install">Install Lossless Scaling in Steam or import Lossless.dll</string>
<string name="lsfg_import">Import Lossless.dll…</string>
<string name="lsfg_using_steam">Using the copy from Steam</string>
<string name="lsfg_using_import">Using an imported copy</string>
<string name="lsfg_pick_title">Choose Lossless.dll</string>
<string name="lsfg_imported">Lossless.dll imported</string>
<string name="lsfg_import_unchanged">This Lossless.dll is already in use</string>
<string name="lsfg_import_not_owned">Lossless Scaling is not owned on this Steam account</string>
<string name="lsfg_import_invalid">This file is not Lossless Scaling\'s Lossless.dll</string>
<string name="lsfg_import_failed">Could not prepare LSFG from this Lossless.dll</string>
<string name="lsfg_problem_missing">LSFG needs Lossless Scaling from Steam or an imported Lossless.dll</string>
<string name="lsfg_problem_failed">LSFG could not prepare its shaders from Lossless.dll</string>
<string name="hud_fg_unsupported">%1$s: unsupported</string>
<string name="hud_fg_failed">%1$s: engine failed</string>
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · starting</string>
<string name="added_games_import_hint">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.</string>
<string name="second_library_import_hint">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.</string>
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
@@ -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))
}
}
@@ -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))
}
}
@@ -1,5 +1,6 @@
#!/bin/bash
# Marks Steam's shared redistributables as already run in every Proton prefix.
# Marks Steam's shared redistributables as already run in every Proton prefix, and in the
# template Proton makes new prefixes from.
#
# A game whose depot lists _CommonRedist makes the client run the real installer inside the
# prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves
@@ -14,27 +15,15 @@ set -u
steam_root=${1:?steam root}
redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
[ -d "$redist" ] || exit 0
# Every process an install script would run becomes the registry value the client writes once it
# has run it. Names sit one level inside "Run Process", the key they share a level further in.
block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk -v now="$(date +%s)" '
function emit(key, name, path) {
path = key
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
if (path == key)
return
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
# both spellings are seeded because the reader picks by its own word size.
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name
}
# Every process an install script would run, as the registry key and value the client writes once
# it has run it. Names sit one level inside "Run Process", the key they share a level further in.
pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk '
FNR == 1 { depth = 0; name = ""; key = "" }
/^[[:space:]]*\{/ { depth++; next }
/^[[:space:]]*\}/ {
if (depth == 3 && name != "" && key != "")
emit(key, name)
printf "%s\t%s\n", key, name
depth--
next
}
@@ -44,15 +33,62 @@ block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | x
if (n >= 3 && depth == 2) { name = part[2]; key = "" }
}
')
[ -n "$block" ] || exit 0
stamp=$(printf '%s' "$block" | cksum)
# The client downloads the scripts with the first game that wants them, which is too late for that
# game; until then the set they held when this was written stands in. (WinNative's list.)
[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN'
DirectX\\Jun2010 dxsetup
.NET\\4.8 4.8
vcredist\\2008 x86 SP1
vcredist\\2008 x64 SP1
vcredist\\2010 x86
vcredist\\2010 x64
vcredist\\2013 x86 12.0.30501
vcredist\\2013 x64 12.0.30501
vcredist\\2015 x86 Update 3 14.0.24215.0
vcredist\\2015 x64 Update 3 14.0.24215.0
vcredist\\2017 x86 14.10.25008.0
vcredist\\2017 x64 14.10.25008.0
vcredist\\2019 x86 14.28.29334.0
vcredist\\2019 x64 14.28.29334.0
vcredist\\2022 x86 14.51.36247.0
vcredist\\2022 x64 14.51.36247.0
KNOWN
)
# The stamp is the set of names, taken before the block below dates its keys: stamped from the
# dated block it never matched, and every prefix had the whole block appended again on every beat.
stamp=$(printf '%s' "$pairs" | cksum)
for reg in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \
block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
{
path = $1
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
if (path == $1)
next
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
# both spellings are seeded because the reader picks by its own word size.
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
}
')
[ -n "$block" ] || exit 0
# Proton builds a new prefix from its template, and the client runs the install scripts in that
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
# changed one as a new template and upgrades every prefix made from the old.
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.bannerlator-redists
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
printf '%s\n' "$block" >>"$reg" || continue
printf '%s\n' "$stamp" >"$seeded"
echo "== seeded redist markers into $reg"
dated=$(mktemp) || continue
touch -r "$reg" "$dated"
if printf '%s\n' "$block" >>"$reg"; then
printf '%s\n' "$stamp" >"$seeded"
echo "== seeded redist markers into $reg"
fi
touch -r "$dated" "$reg"
rm -f "$dated"
done
@@ -273,13 +273,24 @@ case $mode in
# device it took 5 of 8, leaving out both little cores and the fastest one - which is a
# reasonable thing to do while a game is running and the wrong thing when the client is all
# there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns
# children continuously and each one inherits Steam's choice, not ours.
# children continuously and each one inherits Steam's choice, not ours. The beat reads /proc
# itself instead of running pgrep, and only runs taskset for a process that is new or has
# grown threads since it was last pinned: every fork is a round of traced syscalls, and a
# beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because
# without it only a process's main thread moves and the renderer's workers stay where they were.
if [ -n "${BL_CLIENT_CPUS:-}" ]; then
step "client cores: $BL_CLIENT_CPUS"
(
for _ in $(seq 1 720); do
for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do
taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
declare -A _bl_pinned=()
for ((_i = 0; _i < 720; _i++)); do
for _d in /proc/[0-9]*; do
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac
_t=("$_d"/task/*)
_p=${_d#/proc/}
[ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue
taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
_bl_pinned[$_p]=${#_t[@]}
done
nap 5
done
@@ -420,13 +431,33 @@ case $mode in
prev=$app
done
) &
# The library beat. Both helpers are whole programs under proot (the compat one a python
# start and a parse of the client's config), and nearly every beat has nothing new for them.
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
# marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it
# reads from has changed since its last run - a new manifest, a new depot or tool, the client
# rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute
# regardless.
(
beat=0
for _ in $(seq 1 720); do
compat_stamp=$HOME/.bl-compat-beat
rm -f "$compat_stamp"
compat_due() {
local d
[ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0
for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \
"$steam_root/compatibilitytools.d" /mnt/bannerlator-sd/steamapps; do
[ "$d" -nt "$compat_stamp" ] && return 0
done
return 1
}
for ((_i = 0; _i < 720; _i++)); do
nap 5
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
beat=$((beat + 1))
if [ "$((beat % 3))" -eq 0 ]; then
[ "$((beat % 3))" -eq 0 ] || continue
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
if compat_due; then
: > "$compat_stamp"
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
fi
done
@@ -578,6 +609,24 @@ BUSCONF
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
games_watcher=$!
fi
# Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in
# client once a session until the answer is yes. From then on the app offers importing
# Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again.
if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then
(
owned=$HOME/.config/droiddeck/lossless-owned
for ((_i = 0; _i < 60; _i++)); do
nap 30
[ -e "$owned" ] && exit 0
answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue
case $answer in
*'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;;
*) echo "== Lossless Scaling: not owned by this account" ;;
esac
exit 0
done
) &
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
@@ -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):
@@ -128,6 +128,22 @@ def probe(steam_root, ids):
method(client, 1, C.c_bool, C.c_int)(client, pipe)
def game_running():
"""Whether a Proton game is up: a wineserver is there for as long as any prefix is in use."""
try:
pids = [p for p in os.listdir("/proc") if p.isdigit()]
except OSError:
return False
for pid in pids:
try:
with open("/proc/%s/comm" % pid) as f:
if f.read().strip() == "wineserver":
return True
except OSError:
pass
return False
def watch(steam_root, listing):
games = read_json(listing, [])
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
@@ -138,7 +154,10 @@ def watch(steam_root, listing):
# The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership
# only moves with a purchase or a new match, so it runs when the matches or their sources
# change, after a failed probe, and otherwise every five minutes - not on every 30 s beat.
probed, next_probe = None, 0
# A failed probe is retried on a doubling delay up to those five minutes rather than on every
# beat (a client that is not logged on fails it for as long as it stays that way), and none
# starts while a game is running: the answer can wait, the game's cores cannot.
probed, next_probe, failures = None, 0, 0
while True:
now = time.monotonic()
for game in games:
@@ -155,7 +174,9 @@ def watch(steam_root, listing):
next_lookup = now + 300
status = "waiting for a matching Steam title"
key = (dict(candidates), dict(sources))
if candidates and (key != probed or now >= next_probe):
if candidates and (key != probed or now >= next_probe) and game_running():
status = last_status
elif candidates and (key != probed or now >= next_probe):
probed, next_probe = key, now + 300
try:
result = subprocess.run([sys.executable, __file__, "probe", steam_root,
@@ -163,8 +184,10 @@ def watch(steam_root, listing):
capture_output=True, text=True, timeout=15)
if result.returncode != 0:
status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode))
probed = None
failures += 1
next_probe = now + min(300, 30 * 2 ** failures)
else:
failures = 0
line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP="))
snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP="))
if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0:
@@ -175,7 +198,8 @@ def watch(steam_root, listing):
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
status = "ownership check unavailable: " + str(error)
probed = None
failures += 1
next_probe = now + min(300, 30 * 2 ** failures)
elif candidates:
status = last_status
if status != last_status:
+79
View File
@@ -0,0 +1,79 @@
/*
* A zero-timeout KGSL timestamp wait answered as the poll it was meant to be.
*
* Turnip's KGSL backend emulates timeline semaphores with timestamps, and asks "has this one
* retired yet?" by waiting on it with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0.
* KGSL reads a timeout of 0 as no timeout at all, so the poll blocks until the GPU gets there.
* Mesa's timeline bookkeeping polls like that under a lock on every signalling submit, which put
* vkd3d-proton, DXVK, gamescope and Zink in lock-step with the GPU: one frame queued, never two.
* Found and measured by Bannerlator (PROGRESS_LOG 2026-09-29: a D3D12 demo went from 614 to
* 4335 fps with the same answer given in its Vulkan adapter); the driver-side fix is on
* Banners-Turnip, and this covers the drivers that do not carry it yet.
*
* The answer comes from the context's retired timestamp, compared the way KGSL compares them
* (with wrap-around). Anything the read does not answer - an unknown context, a device that is not
* KGSL - goes to the kernel as before. BL_NO_KGSL_POLL_FIX=1 turns it off.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#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;
}
+6 -1
View File
@@ -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;