mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
commit
b31ab5fa96
78 files changed
+1754
-508
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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). */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user