mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge branch 'main' into fix/x360-raw-pad-axes
This commit is contained in:
commit
c0597809ec
89 files changed
+3051
-655
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.
|
||||
@@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
|
||||
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
// The release key's certificate: a build signed with it, from CI or a computer, updates in place.
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
@@ -83,6 +85,7 @@ android {
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
|
||||
|
||||
@@ -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() {
|
||||
@@ -14,6 +16,9 @@ class App : Application() {
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
|
||||
var compCatalogAt by mutableStateOf(0L)
|
||||
var compProton by mutableStateOf<String?>(null)
|
||||
var compComp by mutableStateOf("dxvk")
|
||||
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
|
||||
@@ -10,6 +10,10 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
@@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
|
||||
/**
|
||||
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
|
||||
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
|
||||
* rows to the mode settings dialog.
|
||||
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
|
||||
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
|
||||
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
|
||||
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
|
||||
* screen keeps one and hands it to the Components page's GPU drivers tab.
|
||||
*/
|
||||
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
|
||||
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
private val releaseProgress = HashMap<String, Int>()
|
||||
var linuxSteam by mutableStateOf("")
|
||||
var linuxDesktop by mutableStateOf("")
|
||||
var linuxSelected by mutableStateOf("")
|
||||
val gpu: GpuInfo = GpuInfo.detect()
|
||||
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
|
||||
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
|
||||
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
|
||||
/** The pair being installed, and how far along. */
|
||||
var pairBusy by mutableStateOf<String?>(null)
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
/** What Auto last did or found, for the line under the pair in use. */
|
||||
var autoStatus by mutableStateOf("")
|
||||
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
)
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
fun refreshDrivers() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else DriverRow(
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
|
||||
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
|
||||
linuxSelected = SessionPrefs.linuxDriver(activity)
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
|
||||
)
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
|
||||
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
|
||||
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
|
||||
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
|
||||
*/
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val key = DriverPairs.recommendedKey(gpu, pairs)
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
}, "gpu-driver-auto").start()
|
||||
}
|
||||
|
||||
/** Manual: install (if need be) and set both halves of a pair. */
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download what is missing of [pair], then set both halves - only once both are in, so a
|
||||
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
|
||||
* ones the new pair replaces are removed.
|
||||
*/
|
||||
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
val ids = zips.mapIndexed { i, asset ->
|
||||
installedIdOf(asset)
|
||||
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
|
||||
}
|
||||
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
|
||||
}
|
||||
ui.post {
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "gpu-driver-pair").start()
|
||||
}
|
||||
|
||||
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
|
||||
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
|
||||
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
|
||||
|
||||
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
|
||||
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
|
||||
asset.bundle -> installedBundle(asset)?.id
|
||||
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
|
||||
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* One release asset, downloaded, checked and installed through the importer; returns its id,
|
||||
* a bundle's id for a bundle.
|
||||
*/
|
||||
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
|
||||
var file: File? = null
|
||||
try {
|
||||
file = TurnipReleases.download(activity, asset, progress)
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = when {
|
||||
asset.bundle -> DriverBundle.install(activity, uri).id
|
||||
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
}
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
return id
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
|
||||
* library - then say what happened. A refusal's message is the user-facing reason.
|
||||
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
|
||||
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
|
||||
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
|
||||
*/
|
||||
fun importDriver(uri: Uri, linux: Boolean) {
|
||||
fun importDriver(uri: Uri, linux: Boolean?) {
|
||||
val name = activity.displayNameOf(uri)
|
||||
Thread({
|
||||
var bundle: DriverBundle.Bundle? = null
|
||||
val problem = try {
|
||||
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
else TurnipDriver(activity).installFromZip(uri, name)
|
||||
when {
|
||||
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
|
||||
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
|
||||
else -> try {
|
||||
TurnipDriver(activity).installFromZip(uri, name)
|
||||
} catch (display: IllegalArgumentException) {
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Imported ${name ?: "driver"}",
|
||||
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
|
||||
* sets the bundle's other half in the other list as well.
|
||||
*/
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
setMode(false)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
|
||||
* session never starts on a driver that is gone; a release download is forgotten, so the menu
|
||||
* offers it again.
|
||||
*/
|
||||
fun deleteDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (linux) {
|
||||
LinuxVulkanDriverManager(activity).removeDriver(id)
|
||||
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
|
||||
}
|
||||
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
|
||||
} else {
|
||||
val td = TurnipDriver(activity)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** The download entries and the refresh line, from what the last check found. */
|
||||
fun refreshReleaseRows() {
|
||||
val check = TurnipReleases.cached(activity)
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
fun rows(linux: Boolean) = check?.assets.orEmpty()
|
||||
.filter { it.linux == linux }
|
||||
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip and WinNative…"
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
releaseProgress[assetName] = 0
|
||||
refreshReleaseRows()
|
||||
Thread({
|
||||
var file: java.io.File? = null
|
||||
val problem = try {
|
||||
file = TurnipReleases.download(activity, asset) { pct ->
|
||||
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
|
||||
}
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -28,7 +28,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
@@ -101,6 +101,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var percent by mutableIntStateOf(-1)
|
||||
private var failed by mutableStateOf(false)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
@@ -134,6 +135,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(true)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
@@ -158,6 +160,12 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
@@ -167,6 +175,26 @@ class MainActivity : ComponentActivity() {
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
}
|
||||
|
||||
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
ui.post { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
ui.post {
|
||||
lossless = state
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
@@ -236,6 +264,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
@@ -337,8 +366,8 @@ class MainActivity : ComponentActivity() {
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
|
||||
lsfgReady = LsfgNative.isInstalled(this),
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
isHomeApp = homeAppSelected,
|
||||
@@ -414,10 +443,13 @@ class MainActivity : ComponentActivity() {
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
onRuntime = { onRuntimeButton() },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this, engine, multiplier)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this, mode)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
|
||||
@@ -628,11 +660,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = ComponentsManager.COMPONENTS
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
}
|
||||
@@ -649,8 +681,11 @@ class MainActivity : ComponentActivity() {
|
||||
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
|
||||
}
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
// Opening the app and coming back from a session both land here.
|
||||
updates.onResume()
|
||||
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
|
||||
drivers.ensureAuto(force = false)
|
||||
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
|
||||
// session has just ended). Cheap when nothing is queued.
|
||||
if (!SessionState.running) Thread({
|
||||
@@ -777,14 +812,16 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true) {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -819,6 +856,22 @@ class MainActivity : ComponentActivity() {
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
onAuto = { on -> drivers.setMode(on) },
|
||||
onPair = { key -> drivers.selectPair(key) },
|
||||
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
|
||||
// Picking one driver on its own is Manual: Auto would put its pair back.
|
||||
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
|
||||
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -885,10 +938,8 @@ class MainActivity : ComponentActivity() {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
hdr = hdrOn, hdrReason = hdrReason,
|
||||
linuxRows = drivers.linuxRows,
|
||||
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
|
||||
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
@@ -909,8 +960,6 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
|
||||
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
@@ -921,15 +970,8 @@ class MainActivity : ComponentActivity() {
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRefreshReleases = { drivers.checkLatestTurnip() },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
@@ -995,6 +1037,7 @@ class MainActivity : ComponentActivity() {
|
||||
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
@@ -1003,6 +1046,7 @@ class MainActivity : ComponentActivity() {
|
||||
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
|
||||
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
@@ -1055,6 +1099,7 @@ class MainActivity : ComponentActivity() {
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
@@ -1100,6 +1145,7 @@ class MainActivity : ComponentActivity() {
|
||||
zinkLazy = SessionPrefs.zinkLazy(this)
|
||||
noXalia = SessionPrefs.noXalia(this)
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -33,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
@@ -170,9 +172,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
|
||||
private var frameGenMultiplier by mutableStateOf(2)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
@@ -187,7 +191,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
followPanelRefresh(displayId)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's rate, followed for the rest of the session. It is read once at start, and a
|
||||
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
|
||||
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
|
||||
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
|
||||
*/
|
||||
private fun followPanelRefresh(displayId: Int) {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (display.displayId != displayId) return
|
||||
val hz = display.refreshRate
|
||||
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
@@ -216,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode, and the OS told
|
||||
// it is in gameplay. Logged so a slow device says why.
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this))
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
|
||||
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
|
||||
// says why. The cap is the session's: an activity re-created mid-session keeps it.
|
||||
if (!SessionState.running) {
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(
|
||||
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -338,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Opening the drawer takes the controller away from the game: release its pad.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
@@ -361,8 +389,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
|
||||
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
@@ -371,11 +399,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this@SessionActivity, engine, multiplier)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
applyFrameGen()
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(
|
||||
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
@@ -581,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
frameGenEngine = FrameGen.engine(this)
|
||||
frameGenMultiplier = FrameGen.multiplier(this)
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
@@ -778,7 +808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
CompositorHost.startOrAttach(
|
||||
holder.surface, runtimeDir.path,
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
@@ -887,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hz = refreshHz()
|
||||
Thread({
|
||||
val problem = FrameGen.apply(this, hz)
|
||||
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "frame-gen").start()
|
||||
}
|
||||
|
||||
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
// ── Session state ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Keeps the loading overlay current from the session log, and its clock moving. */
|
||||
@@ -1259,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
*/
|
||||
private fun syncClientCursor(): Boolean {
|
||||
if (!CompositorHost.isStarted) return false
|
||||
// Asked on every pointer move; the image is copied only when the client has changed it.
|
||||
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
|
||||
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
|
||||
if (n < 6) return false
|
||||
val serial = cursorBuf[0]
|
||||
|
||||
@@ -113,19 +113,24 @@ object DeviceReport {
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: ""))
|
||||
val turnip = TurnipDriver(context)
|
||||
val androidChoice = SessionPrefs.androidDriver(context)
|
||||
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
|
||||
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
|
||||
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
val id = SessionPrefs.linuxDriver(context, m)
|
||||
run {
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
|
||||
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
|
||||
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
|
||||
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
|
||||
k("Linux driver ($m)", label)
|
||||
k("Linux driver", label)
|
||||
}
|
||||
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.zip.CRC32
|
||||
/** Windows game folders shared with the Steam session. */
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
private const val LIBRARY = "/mnt/droiddeck-sd"
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
@@ -65,7 +67,7 @@ object AddedGames {
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -108,7 +110,8 @@ object AddedGames {
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
|
||||
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
|
||||
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
|
||||
*
|
||||
* ```
|
||||
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
|
||||
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
|
||||
* linux/ meta.json + libvulkan_freedreno.so
|
||||
* ```
|
||||
*
|
||||
* Installing one puts each half through its own importer, with that importer's checks, and
|
||||
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
|
||||
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
|
||||
*/
|
||||
object DriverBundle {
|
||||
private const val TAG = "DriverBundle"
|
||||
private const val PREFS = "driver_bundles"
|
||||
private const val MANIFEST = "manifest.json"
|
||||
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
|
||||
private const val MAX_BYTES = 512L shl 20
|
||||
|
||||
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
|
||||
|
||||
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
|
||||
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
|
||||
/** "DD-Turnip 0.1.0". */
|
||||
val label: String get() = if (version.isEmpty()) name else "$name $version"
|
||||
}
|
||||
|
||||
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
|
||||
fun parseManifest(json: JSONObject): Manifest? {
|
||||
val drivers = json.optJSONObject("drivers") ?: return null
|
||||
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
|
||||
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
|
||||
if (android == linux) return null
|
||||
return Manifest(
|
||||
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
|
||||
json.optString("tag"), android, linux,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanPath(path: String): String? {
|
||||
val p = path.trim().trim('/')
|
||||
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
|
||||
}
|
||||
|
||||
/** True when the zip at [uri] is a bundle rather than a single driver. */
|
||||
fun isBundle(context: Context, uri: Uri): Boolean = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
|
||||
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "reading $uri as a bundle", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
|
||||
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
|
||||
* is not a usable bundle, [IOException] on read failures.
|
||||
*/
|
||||
fun install(context: Context, uri: Uri): Bundle {
|
||||
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
var androidId: String? = null
|
||||
try {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
val androidStage = td.newStagingDir()
|
||||
androidId = try {
|
||||
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
|
||||
td.adopt(androidStage, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(androidStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val linuxStage = lm.newStagingDir()
|
||||
val linuxId = try {
|
||||
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
|
||||
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
|
||||
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
|
||||
lm.adopt(linuxStage, library?.name, meta, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(linuxStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
|
||||
val saved = registry(context)
|
||||
var id = base
|
||||
var n = 2
|
||||
while (saved.contains(id)) id = "$base-${n++}"
|
||||
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
|
||||
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
|
||||
.put("android", androidId).put("linux", linuxId).toString()).apply()
|
||||
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
|
||||
return bundle
|
||||
} catch (e: Exception) {
|
||||
androidId?.let { td.remove(it) }
|
||||
throw e
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unzip(context: Context, uri: Uri, dest: File) {
|
||||
val root = dest.canonicalFile
|
||||
var total = 0L
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("cannot open $uri")
|
||||
}
|
||||
|
||||
/** Every installed bundle whose two halves are both still there. */
|
||||
fun all(context: Context): List<Bundle> {
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
return registry(context).all.mapNotNull { (id, raw) ->
|
||||
runCatching {
|
||||
val j = JSONObject(raw as String)
|
||||
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
|
||||
}.getOrNull()
|
||||
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
|
||||
|
||||
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
|
||||
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
|
||||
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
|
||||
|
||||
/** The bundle both drivers are set to right now, if they are one. */
|
||||
fun active(context: Context): Bundle? {
|
||||
val android = SessionPrefs.androidDriver(context)
|
||||
val linux = SessionPrefs.linuxDriver(context)
|
||||
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
|
||||
}
|
||||
|
||||
/** Set both halves as the drivers in use. */
|
||||
fun select(context: Context, bundle: Bundle) {
|
||||
SessionPrefs.setAndroidDriver(context, bundle.androidId)
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
/** Delete both halves; a driver still set to either goes back to its default. */
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
|
||||
registry(context).edit().remove(bundle.id).apply()
|
||||
Log.i(TAG, "removed bundle ${bundle.id}")
|
||||
}
|
||||
|
||||
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
|
||||
/**
|
||||
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
|
||||
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
|
||||
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
|
||||
* installs and selects both halves of one pair at once.
|
||||
*
|
||||
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
|
||||
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
|
||||
* newest release that carries both halves.
|
||||
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
|
||||
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
|
||||
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
|
||||
* newest bundle, and it can never be mixed.
|
||||
*/
|
||||
object DriverPairs {
|
||||
const val BANNER = "banner"
|
||||
const val BANNER_A8XX = "banner-a8xx"
|
||||
const val BANNER_710 = "banner-710-720-test"
|
||||
const val BANNER_ONEUI = "banner-8g2-oneui"
|
||||
const val WN_BALANCED = "wn-b"
|
||||
const val WN_PERFORMANCE = "wn-p"
|
||||
const val DD_TURNIP = "dd-turnip"
|
||||
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
|
||||
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
|
||||
|
||||
class DriverPair(
|
||||
val key: String,
|
||||
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
|
||||
val name: String,
|
||||
/** The families the pair is built for; the list only offers it on those unless asked. */
|
||||
val families: Set<Family>,
|
||||
val display: TurnipReleases.Asset?,
|
||||
val linux: TurnipReleases.Asset?,
|
||||
/** Both halves in one zip, instead of [display] and [linux]. */
|
||||
val bundle: TurnipReleases.Asset? = null,
|
||||
) {
|
||||
val complete: Boolean get() = bundle != null || (display != null && linux != null)
|
||||
/** The zips to download for the pair: the bundle, or the two halves. */
|
||||
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
|
||||
/** "r4 · 1.19 + 0.1.2": the build of each half. */
|
||||
val version: String
|
||||
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
|
||||
fun suits(gpu: GpuInfo) = gpu.family in families ||
|
||||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
|
||||
}
|
||||
|
||||
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
|
||||
|
||||
private fun describe(key: String): Pair<String, Set<Family>> = when {
|
||||
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
|
||||
else -> describeFixed(key)
|
||||
}
|
||||
|
||||
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
|
||||
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
|
||||
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
|
||||
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
|
||||
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
|
||||
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
|
||||
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
|
||||
else -> key to ALL_ADRENO
|
||||
}
|
||||
|
||||
/** Every pair the last release check found, in the order the list shows them. */
|
||||
fun from(check: TurnipReleases.Check?): List<DriverPair> {
|
||||
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
|
||||
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
|
||||
return assets.map { it.pair }.distinct()
|
||||
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
|
||||
.map { key ->
|
||||
val (name, families) = describe(key)
|
||||
val halves = assets.filter { it.pair == key }
|
||||
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
|
||||
// The list keeps the newest release of each half; for Banners-Turnip the two halves
|
||||
// must come from the same release, so a set missing one half in its newest release
|
||||
// is offered incomplete rather than mixed with an older one.
|
||||
val display = halves.firstOrNull { !it.linux }
|
||||
val linux = halves.firstOrNull { it.linux }
|
||||
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
|
||||
DriverPair(key, name, families, display, if (sameRelease) linux else null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
|
||||
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
|
||||
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
|
||||
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
|
||||
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
|
||||
*/
|
||||
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): String? = when (gpu.family) {
|
||||
Family.NOT_ADRENO -> null
|
||||
Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED
|
||||
Family.A7XX_LOW -> BANNER_710
|
||||
Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER
|
||||
Family.A6XX -> BANNER
|
||||
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
|
||||
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
|
||||
}
|
||||
|
||||
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
|
||||
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
|
||||
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
|
||||
.maxByOrNull { p ->
|
||||
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
|
||||
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
|
||||
}?.key
|
||||
}
|
||||
@@ -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,80 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
val name: String,
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
|
||||
A7XX_LOW("Adreno 710/720/722"),
|
||||
A7XX("Adreno 7xx"),
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
model >= 800 -> Family.A8XX
|
||||
model in listOf(710, 720, 722) -> Family.A7XX_LOW
|
||||
model >= 700 -> Family.A7XX
|
||||
model >= 600 -> Family.A6XX
|
||||
else -> Family.ADRENO_UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
|
||||
* Linux driver; {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installDriver(Uri zipUri, String displayName) throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
String soName = null;
|
||||
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
|
||||
}
|
||||
}
|
||||
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
|
||||
// library_path is relative to itself, and we write an absolute one below.
|
||||
// library_path is relative to itself, and adopt() writes an absolute one.
|
||||
}
|
||||
}
|
||||
String id = adopt(tmpDir, soName, zipMeta, displayName);
|
||||
keep = true;
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
|
||||
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
|
||||
* deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
|
||||
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
|
||||
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
|
||||
return id;
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException("manifest write failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -179,10 +179,7 @@ public final class TurnipDriver {
|
||||
* {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installFromZip(Uri zipUri, String displayName) throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
|
||||
@@ -198,26 +195,44 @@ public final class TurnipDriver {
|
||||
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
String id = adopt(tmpDir, displayName);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
|
||||
* Returns the id; the caller deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
}
|
||||
|
||||
private static JSONObject readMetaFile(File metaFile) {
|
||||
try {
|
||||
return new JSONObject(FileUtils.readString(metaFile));
|
||||
|
||||
@@ -29,12 +29,20 @@ object TurnipReleases {
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
|
||||
/**
|
||||
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
|
||||
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
|
||||
*/
|
||||
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
|
||||
val sha256: String = "")
|
||||
val sha256: String = "", val pair: String = "", val bundle: Boolean = false)
|
||||
/** [latest] = each source's newest release tag, for the refresh line. */
|
||||
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
|
||||
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
|
||||
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
|
||||
|
||||
private val SOURCES = listOf(
|
||||
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
|
||||
@@ -45,12 +53,14 @@ object TurnipReleases {
|
||||
if (variant.endsWith("-Wayland")) return@Source null
|
||||
val linux = variant.endsWith("-Linux")
|
||||
variant = variant.removeSuffix("-Linux")
|
||||
linux to when {
|
||||
val label = when {
|
||||
variant.isEmpty() -> "Adreno 6xx/7xx"
|
||||
variant == "-A8xx" -> "Adreno 8xx"
|
||||
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
|
||||
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
|
||||
else -> variant.trimStart('-')
|
||||
}
|
||||
Kind(linux, label, "banner" + variant.lowercase())
|
||||
},
|
||||
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
|
||||
// b = Balanced, p = Performance, Axxx = every Adreno.
|
||||
@@ -58,7 +68,13 @@ object TurnipReleases {
|
||||
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
|
||||
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
|
||||
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
|
||||
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
|
||||
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
|
||||
},
|
||||
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
|
||||
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
|
||||
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
|
||||
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
|
||||
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -91,14 +107,19 @@ object TurnipReleases {
|
||||
for (j in 0 until list.length()) {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
val kind = src.classify(name, tag) ?: continue
|
||||
// A bundle is only offered from an official release, never a pre-release.
|
||||
if (kind.bundle && r.optBoolean("prerelease")) continue
|
||||
val linux = kind.linux
|
||||
val label = kind.label
|
||||
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
// Newest first: the first release carrying a variant is the one offered.
|
||||
if (!seen.add("$linux|$label")) continue
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
|
||||
.put("bundle", kind.bundle))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
@@ -136,7 +157,8 @@ object TurnipReleases {
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.getJSONObject(i)
|
||||
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
|
||||
a.optBoolean("bundle", false)))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -112,15 +112,52 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
|
||||
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
|
||||
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
|
||||
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
|
||||
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
|
||||
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
|
||||
* session's choice, the one nearly everyone set, carries over.
|
||||
*/
|
||||
fun linuxDriver(context: Context, mode: String): String =
|
||||
prefs(context).getString("linuxDriver.$mode", "") ?: ""
|
||||
fun linuxDriver(context: Context): String =
|
||||
prefs(context).getString("linuxDriver", null)
|
||||
?: prefs(context).getString("linuxDriver.steam", null)
|
||||
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
|
||||
|
||||
fun setLinuxDriver(context: Context, mode: String, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
|
||||
fun setLinuxDriver(context: Context, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver", id).apply()
|
||||
}
|
||||
|
||||
const val GPU_DRIVERS_AUTO = "auto"
|
||||
const val GPU_DRIVERS_MANUAL = "manual"
|
||||
|
||||
/**
|
||||
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
|
||||
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
|
||||
* installs; see settleGpuDriverMode.
|
||||
*/
|
||||
fun gpuDriverMode(context: Context): String =
|
||||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||||
|
||||
fun setGpuDriverMode(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto arrived after people had picked drivers by hand: an install that chose either driver
|
||||
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
|
||||
*/
|
||||
fun settleGpuDriverMode(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("gpuDriverMode")) return
|
||||
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
|
||||
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
|
||||
}
|
||||
|
||||
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
|
||||
fun gpuAutoInstalled(context: Context): Set<String> =
|
||||
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
|
||||
|
||||
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
|
||||
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +283,13 @@ object SessionPrefs {
|
||||
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
|
||||
* but correct. Max's advice for devices whose kernels "don't work well with it".
|
||||
*/
|
||||
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
|
||||
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
|
||||
|
||||
fun setGpuClockPin(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||||
}
|
||||
|
||||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||||
|
||||
fun setProotNoSeccomp(context: Context, on: Boolean) {
|
||||
@@ -519,6 +563,22 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
|
||||
* -r (what the client and its games see as the display's rate), the compositor's buffer release
|
||||
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
|
||||
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
|
||||
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
|
||||
* does. Applies next session.
|
||||
*/
|
||||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
|
||||
|
||||
fun setFpsLimit(context: Context, mode: String, fps: Int) {
|
||||
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
|
||||
}
|
||||
|
||||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||||
|
||||
/**
|
||||
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
|
||||
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
|
||||
@@ -539,7 +599,7 @@ object SessionPrefs {
|
||||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and
|
||||
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
|
||||
* registered with the client, which then asks where to install every game and shows both
|
||||
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
|
||||
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
|
||||
|
||||
@@ -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))
|
||||
@@ -498,15 +503,12 @@ class SessionService : Service() {
|
||||
guest.add("GALLIUM_DRIVER=zink")
|
||||
guest.add("LIBGL_KOPPER_DRI2=true")
|
||||
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
|
||||
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
|
||||
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
|
||||
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
|
||||
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
|
||||
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
|
||||
// build, and with two drivers every emulator compiled its shaders twice - once per way of
|
||||
// starting it (RPCS3's 6650 interpreter variants on each first boot).
|
||||
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
|
||||
// An imported glibc Turnip, when one is set (by the user, or by Auto): the session script
|
||||
// checks the manifest and its library from inside and points the loader at it with
|
||||
// VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad
|
||||
// import falls back to. One driver for every session: the driver's shader cache is keyed on
|
||||
// its build, and with one per mode every emulator compiled its shaders twice.
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
|
||||
@@ -527,6 +529,12 @@ class SessionService : Service() {
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
// file cache is an open and every store an open and a rename, each a proot stop (a rename
|
||||
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
|
||||
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
|
||||
// the old folder itself once it has gone a week untouched.
|
||||
guest.add("MESA_DISK_CACHE_DATABASE=1")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
@@ -689,9 +697,12 @@ class SessionService : Service() {
|
||||
if (library != null) {
|
||||
val problem = GameStorage.prepare(library.path)
|
||||
if (problem == null) {
|
||||
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/droiddeck-sd")
|
||||
// Links, prefixes and Steam entries made before the rename still name the old path.
|
||||
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/bannerlator-sd")
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
|
||||
} else {
|
||||
Log.w(TAG, "game storage: $problem; internal only this session")
|
||||
}
|
||||
@@ -967,6 +978,31 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
|
||||
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
|
||||
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
|
||||
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
|
||||
* proot itself and would inherit the value, and with it everything the session starts.
|
||||
*/
|
||||
private fun raiseTracer(pid: Int, gen: Int) {
|
||||
Thread({
|
||||
try {
|
||||
Thread.sleep(2000)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (gen != sessionGen || sessionPid != pid) return@Thread
|
||||
val nice = try {
|
||||
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "tracer priority", t)
|
||||
return@Thread
|
||||
}
|
||||
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
|
||||
}, "tracer-priority").start()
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
@@ -975,6 +1011,7 @@ class SessionService : Service() {
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
suspendOperationPending = false
|
||||
launchWatcher?.stopWatching()
|
||||
@@ -1151,6 +1188,8 @@ class SessionService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
|
||||
private const val TRACER_NICE = -6
|
||||
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +102,9 @@ fun ComponentsPage(
|
||||
onImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
requestInitialFocus: Boolean = true,
|
||||
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
|
||||
gpu: GpuDriversState = GpuDriversState(),
|
||||
gpuActions: GpuDriversActions = GpuDriversActions(),
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -112,23 +115,55 @@ fun ComponentsPage(
|
||||
var about by remember { mutableStateOf(false) }
|
||||
var protonMenu by remember { mutableStateOf(false) }
|
||||
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
|
||||
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
val gpuTab = comp == GPU_TAB
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
|
||||
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
|
||||
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = gpu.canRestoreBundled,
|
||||
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val views = snapshot?.protons ?: emptyList()
|
||||
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
|
||||
val label = ComponentsManager.LABEL[comp] ?: comp
|
||||
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
|
||||
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
// ---- title, and the one thing that goes online -----------------------------------------
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 1, modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
// The status gives way to the title, not the other way round.
|
||||
if (!narrow) Text(
|
||||
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
) else Spacer(Modifier.weight(1f))
|
||||
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
|
||||
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
}
|
||||
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -138,7 +173,7 @@ fun ComponentsPage(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) {
|
||||
if (view != null) Box {
|
||||
if (view != null && !gpuTab) Box {
|
||||
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
|
||||
@@ -152,11 +187,12 @@ fun ComponentsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
|
||||
val tabs = listOf(GPU_TAB) + comps
|
||||
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
|
||||
}
|
||||
|
||||
// ---- what is in use -----------------------------------------------------------------------
|
||||
if (view != null) {
|
||||
if (view != null && !gpuTab) {
|
||||
val st = view.components.getValue(comp)
|
||||
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
|
||||
Row(
|
||||
@@ -179,6 +215,12 @@ fun ComponentsPage(
|
||||
|
||||
// ---- the lists ------------------------------------------------------------------------------
|
||||
when {
|
||||
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
|
||||
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
|
||||
Row(modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
|
||||
}
|
||||
}
|
||||
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
|
||||
const val GPU_TAB = "gpu"
|
||||
@@ -141,6 +141,7 @@ fun DriverPage(
|
||||
text = {
|
||||
Text(
|
||||
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
|
||||
else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default."
|
||||
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -151,7 +153,9 @@ internal fun SetupPanel(
|
||||
}
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val gpuOk = remember { DeviceSupport.adreno() }
|
||||
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
|
||||
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
|
||||
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
|
||||
val gpuName = remember { DeviceSupport.gpuName() }
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
@@ -193,8 +197,16 @@ internal fun SetupPanel(
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
CheckRow(
|
||||
if (gpuOk) CheckState.OK else CheckState.WARN,
|
||||
if (gpuOk) "Device supported" else "GPU not supported",
|
||||
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU"
|
||||
else -> "GPU not supported"
|
||||
},
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}"
|
||||
else -> "Steam draws with an Adreno driver; $gpuName may show a black screen"
|
||||
},
|
||||
)
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
@@ -269,7 +281,7 @@ internal fun SetupPanel(
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow("Frame generation", "Select the frame generation mode") {
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(s, a, host)
|
||||
@@ -383,7 +395,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
|
||||
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,7 +411,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
Box(m) {
|
||||
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
host.open = if (host.open == "fg") null else "fg"
|
||||
}
|
||||
FrameGenMenu(s, a, host)
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
|
||||
class PairRow(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val version: String,
|
||||
val detail: String,
|
||||
val recommended: Boolean,
|
||||
/** Built for this GPU's family; the others only show when asked. */
|
||||
val suits: Boolean,
|
||||
val complete: Boolean,
|
||||
val installed: Boolean,
|
||||
val active: Boolean,
|
||||
)
|
||||
|
||||
class GpuDriversState(
|
||||
val gpuName: String = "",
|
||||
val gpuFamily: String = "",
|
||||
val soc: String = "",
|
||||
val supportText: String = "",
|
||||
val supported: Boolean = true,
|
||||
val unsupported: Boolean = false,
|
||||
val auto: Boolean = true,
|
||||
val pairs: List<PairRow> = emptyList(),
|
||||
/** The pair key being installed, and how far along (-1 = unknown). */
|
||||
val busy: String? = null,
|
||||
val percent: Int = -1,
|
||||
val autoStatus: String = "",
|
||||
val releaseStatus: String = "",
|
||||
val checking: Boolean = false,
|
||||
// Advanced: each driver on its own.
|
||||
val linuxRows: List<DriverRow> = emptyList(),
|
||||
val linuxSelected: String = "",
|
||||
val androidRows: List<DriverRow> = emptyList(),
|
||||
val androidSelected: String = "",
|
||||
val linuxDownloads: List<DownloadRow> = emptyList(),
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val canRestoreBundled: Boolean = false,
|
||||
/** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */
|
||||
val activeBundle: String? = null,
|
||||
) {
|
||||
/** The pair both drivers are set to, if they make one. */
|
||||
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
|
||||
}
|
||||
|
||||
class GpuDriversActions(
|
||||
val onAuto: (Boolean) -> Unit = {},
|
||||
val onPair: (String) -> Unit = {},
|
||||
val onRefresh: () -> Unit = {},
|
||||
val onSelectLinux: (String) -> Unit = {},
|
||||
val onSelectAndroid: (String) -> Unit = {},
|
||||
val onRemoveLinux: (String) -> Unit = {},
|
||||
val onRemoveAndroid: (String) -> Unit = {},
|
||||
val onDownloadDriver: (String) -> Unit = {},
|
||||
val onImportLinux: () -> Unit = {},
|
||||
val onImportAndroid: () -> Unit = {},
|
||||
/** The tab's own import: a bundle, or a single driver of either kind. */
|
||||
val onImportZip: () -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
)
|
||||
|
||||
/**
|
||||
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
|
||||
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
|
||||
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
|
||||
* two lists each on their own, and imports, are under Advanced.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup("Advanced") {
|
||||
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
|
||||
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
|
||||
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
|
||||
*/
|
||||
@Composable
|
||||
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
for (row in rows) DropdownMenuItem(
|
||||
leadingIcon = { Radio(row.id == selected) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What Auto has set and what it is doing. */
|
||||
@Composable
|
||||
private fun AutoCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val active = s.activePair
|
||||
val recommended = s.pairs.firstOrNull { it.recommended }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
|
||||
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (s.percent.coerceAtLeast(0)) / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Radio(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Radio(p.active)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
when {
|
||||
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(selected: Boolean) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
|
||||
const val BUNDLED = "BUNDLED"
|
||||
const val DOWNLOADED = "DOWNLOADED"
|
||||
const val IMPORTED = "IMPORTED"
|
||||
/** One half of an Android + Linux bundle: picked and deleted together with the other. */
|
||||
const val BUNDLE = "ANDROID + LINUX"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +49,10 @@ class ModeSettings(
|
||||
val shapeMode: String,
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
val linuxRows: List<DriverRow>,
|
||||
val linuxSelected: String,
|
||||
val androidRows: List<DriverRow>,
|
||||
val androidSelected: String,
|
||||
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
|
||||
val gpuDrivers: String = "Auto",
|
||||
/** Frames per second the session is capped at; 0 = none. */
|
||||
val fpsLimit: Int = 0,
|
||||
val touchMode: String,
|
||||
val suspendPolicy: String,
|
||||
/** Steam only. */
|
||||
@@ -81,12 +83,6 @@ class ModeSettings(
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
val addedGamesArt: Boolean = true,
|
||||
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
|
||||
val linuxDownloads: List<DownloadRow> = emptyList(),
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
|
||||
val releaseChecking: Boolean = false,
|
||||
/** A bundled display driver was deleted: the page offers to restore it. */
|
||||
val canRestoreBundled: Boolean = false,
|
||||
/** Steam only: Decky Loader is managed from the Steam session settings. */
|
||||
val deckyInstalled: String? = null,
|
||||
val deckyLatestRelease: DeckyManager.Release? = null,
|
||||
@@ -106,16 +102,9 @@ class ModeSettingsActions(
|
||||
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
|
||||
val onShape: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
val onSelectLinux: (String) -> Unit,
|
||||
val onImportLinux: () -> Unit,
|
||||
val onRemoveLinux: (String) -> Unit,
|
||||
val onRefreshReleases: () -> Unit = {},
|
||||
/** Asset name of the release driver to download. */
|
||||
val onDownloadDriver: (String) -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onSelectAndroid: (String) -> Unit,
|
||||
val onImportAndroid: () -> Unit,
|
||||
val onRemoveAndroid: (String) -> Unit,
|
||||
/** Opens the GPU drivers on the Components page: they are shared by every session. */
|
||||
val onGpuDrivers: () -> Unit = {},
|
||||
val onFpsLimit: (Int) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onOsc: (String) -> Unit,
|
||||
@@ -150,51 +139,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
|
||||
// Coming back restores this page as it was left: the same scroll position, and controller focus
|
||||
// on the driver box that opened the page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
var returnTo by remember { mutableStateOf<String?>(null) }
|
||||
val pageScroll = androidx.compose.foundation.rememberScrollState()
|
||||
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
|
||||
androidx.compose.runtime.LaunchedEffect(driverPage) {
|
||||
if (driverPage == null) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
|
||||
// from a driver page, it returns to the driver box that opened it.
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver",
|
||||
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
|
||||
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver",
|
||||
hint = "Used by the compositor in both modes. Restart the app to apply.",
|
||||
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = s.canRestoreBundled,
|
||||
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { firstChip.requestFocus() }
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
@@ -220,6 +171,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "fps", "Frame limit", "Applies next session.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
|
||||
note = "Caps the whole session. The screen switches to a rate the cap divides evenly.",
|
||||
onPick = a.onFpsLimit,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
initial = custom,
|
||||
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
|
||||
@@ -235,17 +192,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
SettingsGroup("Drivers") {
|
||||
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
|
||||
ValueChip(
|
||||
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
|
||||
) { openDriverPage("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
|
||||
ValueChip(
|
||||
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
|
||||
) { openDriverPage("panel") }
|
||||
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -113,9 +113,11 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
@@ -157,9 +159,8 @@ class DrawerActions(
|
||||
val isHomeApp: Boolean,
|
||||
val androidApps: List<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,
|
||||
@@ -173,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... */
|
||||
@@ -345,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
|
||||
)
|
||||
val fgOpen = host.open == "fg"
|
||||
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
|
||||
SettingsRow("Frame generation", null, highlighted = fgOpen) {
|
||||
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
|
||||
Box {
|
||||
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
|
||||
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
|
||||
FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
if (LocalNarrowPane.current) {
|
||||
StatusPanel(s, u, ua, me)
|
||||
Box(Modifier.height(18.dp))
|
||||
ChannelLabel()
|
||||
ChannelPicker(u, ua)
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
} else {
|
||||
// The label sits over both columns, so the status card lines up with the first channel.
|
||||
ChannelLabel()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,13 +145,13 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
val pal = LocalPalette.current
|
||||
val catalog = u.catalog
|
||||
val release = catalog?.let { AppUpdates.release(it, u.follow) }
|
||||
val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
|
||||
val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
|
||||
val name = channelName(u.follow)
|
||||
val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH)
|
||||
class Look(val tint: Color, val status: String, val headline: String, val detail: String?)
|
||||
val look = when {
|
||||
!me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer",
|
||||
"It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.")
|
||||
!me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place",
|
||||
"This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.")
|
||||
catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null)
|
||||
offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null)
|
||||
offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null)
|
||||
@@ -235,13 +240,20 @@ private fun changeTitle(t: String): String =
|
||||
private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB"
|
||||
|
||||
/** The three channels as cards to pick from; Test builds opens its list of PRs under it. */
|
||||
@Composable
|
||||
private fun ChannelLabel() {
|
||||
Text(
|
||||
"UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val catalog = u.catalog
|
||||
val tests = catalog?.tests.orEmpty()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
|
||||
ChannelCard(
|
||||
Icons.Outlined.Verified, "Stable", "Tested releases, for most people",
|
||||
catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import org.json.JSONArray
|
||||
@@ -8,6 +10,7 @@ import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
@@ -47,8 +50,11 @@ object AppUpdates {
|
||||
|
||||
class Catalog(val stable: Release?, val nightly: Release?, val tests: List<Release>, val checkedAt: Long)
|
||||
|
||||
/** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */
|
||||
class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L)
|
||||
/**
|
||||
* What is running: the commit it was built from, its PR (0 for none), whether the builds here
|
||||
* install over it (it is signed with the release key), and when it was committed.
|
||||
*/
|
||||
class Installed(val commit: String, val pr: Int, val version: String, val updatable: Boolean, val committedAt: Long = 0L)
|
||||
|
||||
enum class Offer {
|
||||
/** This is the build the channel has. */
|
||||
@@ -63,7 +69,28 @@ object AppUpdates {
|
||||
GONE,
|
||||
}
|
||||
|
||||
fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME)
|
||||
@Volatile private var releaseSigned = BuildConfig.CI_BUILD
|
||||
|
||||
/** From App.onCreate: whether this copy is signed with the release key, which a local build may be too. */
|
||||
fun init(context: Context) {
|
||||
releaseSigned = runCatching { signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } }
|
||||
.getOrDefault(BuildConfig.CI_BUILD)
|
||||
}
|
||||
|
||||
/** SHA-256 of each certificate the package is signed with, including the ones a rotated key vouches for. */
|
||||
private fun signers(context: Context): List<String> {
|
||||
val pm = context.packageManager
|
||||
@Suppress("DEPRECATION")
|
||||
val certs = if (Build.VERSION.SDK_INT >= 28) {
|
||||
val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo ?: return emptyList()
|
||||
if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory
|
||||
} else {
|
||||
pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures
|
||||
} ?: return emptyList()
|
||||
return certs.map { cert -> MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } }
|
||||
}
|
||||
|
||||
fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, releaseSigned, BuildConfig.COMMIT_TIME)
|
||||
|
||||
fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) {
|
||||
Channel.STABLE -> catalog.stable
|
||||
@@ -87,7 +114,7 @@ object AppUpdates {
|
||||
|
||||
/** The dot on the rail: the followed channel has a newer build of what is running. */
|
||||
fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean =
|
||||
catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE
|
||||
catalog != null && installed().updatable && offer(catalog, follow) == Offer.UPDATE
|
||||
|
||||
fun isRunning(r: Release, me: Installed = installed()): Boolean {
|
||||
if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false
|
||||
@@ -105,7 +132,7 @@ object AppUpdates {
|
||||
me.pr != 0 -> Follow(Channel.TEST, me.pr)
|
||||
catalog == null -> return Follow(Channel.STABLE)
|
||||
catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE)
|
||||
me.ci -> Follow(Channel.NIGHTLY)
|
||||
me.updatable -> Follow(Channel.NIGHTLY)
|
||||
else -> Follow(Channel.STABLE)
|
||||
}
|
||||
setFollow(context, inferred)
|
||||
|
||||
@@ -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,133 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class DriverBundleTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "graphics_driver").deleteRecursively()
|
||||
File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively()
|
||||
context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
SessionPrefs.setAndroidDriver(context, "")
|
||||
SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
/** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */
|
||||
private fun library(libc: String) = ByteArray(64).also {
|
||||
byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it)
|
||||
it[18] = 0xB7.toByte()
|
||||
} + libc.toByteArray() + ByteArray(1)
|
||||
|
||||
private val manifest = JSONObject()
|
||||
.put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0")
|
||||
.put("drivers", JSONObject()
|
||||
.put("android", JSONObject().put("path", "android"))
|
||||
.put("linux", JSONObject().put("path", "linux")))
|
||||
|
||||
private fun zip(entries: Map<String, ByteArray>): Uri {
|
||||
val file = File.createTempFile("bundle", ".zip", context.cacheDir)
|
||||
ZipOutputStream(file.outputStream()).use { z ->
|
||||
for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() }
|
||||
}
|
||||
return Uri.fromFile(file)
|
||||
}
|
||||
|
||||
private fun bundle(linuxLibc: String = "libc.so.6", extra: Map<String, ByteArray> = emptyMap()) = zip(mapOf(
|
||||
"android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(),
|
||||
"android/libvulkan_freedreno.so" to library("libc.so"),
|
||||
"linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(),
|
||||
"linux/libvulkan_freedreno.so" to library(linuxLibc),
|
||||
"manifest.json" to manifest.toString().toByteArray(),
|
||||
) + extra)
|
||||
|
||||
@Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() {
|
||||
val m = DriverBundle.parseManifest(manifest)!!
|
||||
assertEquals("android", m.androidPath)
|
||||
assertEquals("linux", m.linuxPath)
|
||||
assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a")))))
|
||||
val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") }
|
||||
assertNull(DriverBundle.parseManifest(same))
|
||||
val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") }
|
||||
assertNull(DriverBundle.parseManifest(escape))
|
||||
}
|
||||
|
||||
@Test fun singleDriverZipsAreNotBundles() {
|
||||
val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so")))
|
||||
assertFalse(DriverBundle.isBundle(context, legacy))
|
||||
assertTrue(DriverBundle.isBundle(context, bundle()))
|
||||
}
|
||||
|
||||
@Test fun installsBothHalvesAndPicksAndDeletesThemTogether() {
|
||||
val b = DriverBundle.install(context, bundle())
|
||||
assertEquals("DD-Turnip-v0.1.0", b.id)
|
||||
assertEquals("DD-Turnip 0.1.0", b.label)
|
||||
assertTrue(TurnipDriver(context).isInstalled(b.androidId))
|
||||
assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
|
||||
assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id)
|
||||
assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id)
|
||||
|
||||
assertNull(DriverBundle.active(context))
|
||||
DriverBundle.select(context, b)
|
||||
assertEquals(b.androidId, SessionPrefs.androidDriver(context))
|
||||
assertEquals(b.linuxId, SessionPrefs.linuxDriver(context))
|
||||
assertNotNull(DriverBundle.active(context))
|
||||
|
||||
DriverBundle.remove(context, b)
|
||||
assertFalse(TurnipDriver(context).isInstalled(b.androidId))
|
||||
assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
|
||||
assertEquals("", SessionPrefs.androidDriver(context))
|
||||
assertEquals("", SessionPrefs.linuxDriver(context))
|
||||
assertTrue(DriverBundle.all(context).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aRefusedHalfLeavesNothingInstalled() {
|
||||
try {
|
||||
DriverBundle.install(context, bundle(linuxLibc = "libc.so"))
|
||||
fail("a bionic library in linux/ must be refused")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertTrue(e.message!!.contains("glibc"))
|
||||
}
|
||||
assertTrue(TurnipDriver(context).enumerateImported().isEmpty())
|
||||
assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun entriesOutsideTheBundleAreRefused() {
|
||||
try {
|
||||
DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4))))
|
||||
fail("a zip-slip entry must be refused")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertTrue(e.message!!.contains("outside"))
|
||||
}
|
||||
assertFalse(File(context.cacheDir.parentFile, "escape.so").exists())
|
||||
}
|
||||
|
||||
@Test fun twoInstallsOfOneReleaseGetTheirOwnIds() {
|
||||
val first = DriverBundle.install(context, bundle())
|
||||
val second = DriverBundle.install(context, bundle())
|
||||
assertEquals("DD-Turnip-v0.1.0-2", second.id)
|
||||
assertEquals(2, DriverBundle.all(context).size)
|
||||
assertFalse(first.androidId == second.androidId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DriverPairsTest {
|
||||
private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) =
|
||||
GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi)
|
||||
|
||||
private fun asset(name: String, tag: String, linux: Boolean, pair: String) =
|
||||
TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair)
|
||||
|
||||
@Test
|
||||
fun familiesFollowTheAdrenoModel() {
|
||||
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825))
|
||||
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810))
|
||||
assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722))
|
||||
assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740))
|
||||
assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610))
|
||||
assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0))
|
||||
assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTestedHardwareCountsAsSupported() {
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(740).support)
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(825).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support)
|
||||
assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() {
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList()))
|
||||
assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList()))
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList()))
|
||||
assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairsMatchHalvesAndNeverMixBannersReleases() {
|
||||
val check = TurnipReleases.Check(
|
||||
listOf(
|
||||
asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER),
|
||||
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER),
|
||||
// The A8xx set's newest display build is r4, but its Linux half is only in r3.
|
||||
asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX),
|
||||
asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX),
|
||||
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
|
||||
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED),
|
||||
),
|
||||
emptyList(), emptyList(), 0L,
|
||||
)
|
||||
val pairs = DriverPairs.from(check).associateBy { it.key }
|
||||
assertTrue(pairs.getValue(DriverPairs.BANNER).complete)
|
||||
assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete)
|
||||
val wn = pairs.getValue(DriverPairs.WN_BALANCED)
|
||||
assertTrue("WinNative's two release lines pair up", wn.complete)
|
||||
assertEquals("v1.19 + v0.1.2", wn.version)
|
||||
assertTrue(wn.suits(gpu(830)))
|
||||
assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() {
|
||||
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
|
||||
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
|
||||
val newer = bundle("DD-Turnip-v0.2.0")
|
||||
val older = bundle("DD-Turnip-v0.1.0")
|
||||
val pairs = DriverPairs.from(TurnipReleases.Check(
|
||||
listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older,
|
||||
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)),
|
||||
emptyList(), emptyList(), 0L,
|
||||
))
|
||||
assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key })
|
||||
val dd = pairs.first()
|
||||
assertTrue(dd.complete)
|
||||
assertEquals(listOf(newer), dd.assets)
|
||||
assertEquals("v0.2.0", dd.version)
|
||||
assertEquals("DroidDeck · DD-Turnip", dd.name)
|
||||
assertTrue(dd.suits(gpu(840)))
|
||||
assertTrue(dd.suits(gpu(740)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adreno840GetsTheNewestOfficialDdTurnip() {
|
||||
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
|
||||
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
|
||||
val pairs = DriverPairs.from(TurnipReleases.Check(
|
||||
listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"),
|
||||
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
|
||||
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)),
|
||||
emptyList(), emptyList(), 0L,
|
||||
))
|
||||
assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs))
|
||||
assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs))
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class AppUpdatesTest {
|
||||
checkedAt = 0L,
|
||||
)
|
||||
|
||||
private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, ci = true)
|
||||
private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, updatable = true)
|
||||
|
||||
@Test
|
||||
fun theStableBuildIsCurrentOnStable() {
|
||||
|
||||
@@ -21,16 +21,21 @@ The resulting snapshot is stored under that account's `userdata/<id>/config` and
|
||||
refreshed every 30 seconds while logged in. Title lookups retry every five minutes and
|
||||
when the source changes, so an early network failure does not end the watcher. Changed
|
||||
status and probe failures are written to the session log. Startup and post-exit import
|
||||
use the last successful snapshot for the selected account. A newly identified title
|
||||
therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts
|
||||
again. An unavailable client or offline first launch falls back to shortcuts; existing
|
||||
use the last successful snapshot for the selected account. The client reads manifests
|
||||
and shortcuts only at start, so when a snapshot confirms owned titles that are not yet
|
||||
imported, the watcher restarts the client once (not while a game runs or a download is in
|
||||
progress) and the importer runs between the exit and the next start. An unavailable client or offline first launch falls back to shortcuts; existing
|
||||
snapshots remain usable offline. Steam still enforces the current license at launch.
|
||||
|
||||
For a confirmed game without a manifest, the importer links the source folder into the
|
||||
internal Steam library and writes an installed manifest with `Universe=1` and
|
||||
`StateFlags=4`. Steam ignores manifests without the universe field. This registers
|
||||
existing files; it does not assert a verified build ID or depot manifest. Steam can
|
||||
still require verification, updates or redistributables. Steam updates and uninstall
|
||||
`StateFlags=4`. Steam ignores manifests without the universe field. A manifest without
|
||||
installed depots makes Steam verify every file, so the importer carries the build another
|
||||
client recorded: WinNative's `.winnative-steam/steamapps` manifest beside the game folder,
|
||||
or a completed download's `.DepotDownloader/depot.config` depot manifest IDs. Steam then
|
||||
only downloads what changed since that build. Bare manifests from earlier imports are
|
||||
upgraded while Steam has not touched them. Without either record Steam still verifies.
|
||||
WinNative's manifest also identifies the folder without a store lookup. Steam updates and uninstall
|
||||
operate on the linked game files, so imported folders must be writable. Existing Steam
|
||||
manifests and directories are preserved. Only the exact minimal manifests generated by
|
||||
the earlier importer are migrated, with the matching source symlink still present. If
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
|
||||
HOME = os.environ.get("HOME", "/root")
|
||||
STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam")
|
||||
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/bannerlator-sd", "SD Games"))
|
||||
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games"))
|
||||
# The client's own tools and runtimes live in steamapps beside the games; they are not titles.
|
||||
NOT_GAMES = {228980, 1493710, 3127680, 4183110, 4427310, 4185400}
|
||||
APPS_DIR = os.path.join(HOME, ".local/share/applications")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Marks Steam's shared redistributables as already run in every Proton prefix.
|
||||
# Marks Steam's shared redistributables as already run in every Proton prefix, and in the
|
||||
# template Proton makes new prefixes from.
|
||||
#
|
||||
# A game whose depot lists _CommonRedist makes the client run the real installer inside the
|
||||
# prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves
|
||||
@@ -14,27 +15,15 @@ set -u
|
||||
steam_root=${1:?steam root}
|
||||
|
||||
redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
|
||||
[ -d "$redist" ] || exit 0
|
||||
|
||||
# Every process an install script would run becomes the registry value the client writes once it
|
||||
# has run it. Names sit one level inside "Run Process", the key they share a level further in.
|
||||
block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk -v now="$(date +%s)" '
|
||||
function emit(key, name, path) {
|
||||
path = key
|
||||
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
|
||||
if (path == key)
|
||||
return
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name
|
||||
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
|
||||
# both spellings are seeded because the reader picks by its own word size.
|
||||
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name
|
||||
}
|
||||
# Every process an install script would run, as the registry key and value the client writes once
|
||||
# it has run it. Names sit one level inside "Run Process", the key they share a level further in.
|
||||
pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk '
|
||||
FNR == 1 { depth = 0; name = ""; key = "" }
|
||||
/^[[:space:]]*\{/ { depth++; next }
|
||||
/^[[:space:]]*\}/ {
|
||||
if (depth == 3 && name != "" && key != "")
|
||||
emit(key, name)
|
||||
printf "%s\t%s\n", key, name
|
||||
depth--
|
||||
next
|
||||
}
|
||||
@@ -44,15 +33,62 @@ block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | x
|
||||
if (n >= 3 && depth == 2) { name = part[2]; key = "" }
|
||||
}
|
||||
')
|
||||
[ -n "$block" ] || exit 0
|
||||
stamp=$(printf '%s' "$block" | cksum)
|
||||
# The client downloads the scripts with the first game that wants them, which is too late for that
|
||||
# game; until then the set they held when this was written stands in. (WinNative's list.)
|
||||
[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN'
|
||||
DirectX\\Jun2010 dxsetup
|
||||
.NET\\4.8 4.8
|
||||
vcredist\\2008 x86 SP1
|
||||
vcredist\\2008 x64 SP1
|
||||
vcredist\\2010 x86
|
||||
vcredist\\2010 x64
|
||||
vcredist\\2013 x86 12.0.30501
|
||||
vcredist\\2013 x64 12.0.30501
|
||||
vcredist\\2015 x86 Update 3 14.0.24215.0
|
||||
vcredist\\2015 x64 Update 3 14.0.24215.0
|
||||
vcredist\\2017 x86 14.10.25008.0
|
||||
vcredist\\2017 x64 14.10.25008.0
|
||||
vcredist\\2019 x86 14.28.29334.0
|
||||
vcredist\\2019 x64 14.28.29334.0
|
||||
vcredist\\2022 x86 14.51.36247.0
|
||||
vcredist\\2022 x64 14.51.36247.0
|
||||
KNOWN
|
||||
)
|
||||
# The stamp is the set of names, taken before the block below dates its keys: stamped from the
|
||||
# dated block it never matched, and every prefix had the whole block appended again on every beat.
|
||||
stamp=$(printf '%s' "$pairs" | cksum)
|
||||
|
||||
for reg in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \
|
||||
/mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do
|
||||
block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
|
||||
{
|
||||
path = $1
|
||||
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
|
||||
if (path == $1)
|
||||
next
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
|
||||
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
|
||||
# both spellings are seeded because the reader picks by its own word size.
|
||||
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
|
||||
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
|
||||
}
|
||||
')
|
||||
[ -n "$block" ] || exit 0
|
||||
|
||||
# Proton builds a new prefix from its template, and the client runs the install scripts in that
|
||||
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
|
||||
# changed one as a new template and upgrades every prefix made from the old.
|
||||
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
|
||||
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
|
||||
[ -f "$reg" ] || continue
|
||||
seeded=$(dirname "$reg")/.bannerlator-redists
|
||||
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
|
||||
printf '%s\n' "$block" >>"$reg" || continue
|
||||
printf '%s\n' "$stamp" >"$seeded"
|
||||
echo "== seeded redist markers into $reg"
|
||||
dated=$(mktemp) || continue
|
||||
touch -r "$reg" "$dated"
|
||||
if printf '%s\n' "$block" >>"$reg"; then
|
||||
printf '%s\n' "$stamp" >"$seeded"
|
||||
echo "== seeded redist markers into $reg"
|
||||
fi
|
||||
touch -r "$dated" "$reg"
|
||||
rm -f "$dated"
|
||||
done
|
||||
@@ -234,7 +234,7 @@ case $mode in
|
||||
bannerlator-steam-install || exit $?
|
||||
step "Steam client ready"
|
||||
steam_root=$XDG_DATA_HOME/Steam
|
||||
# Register the card library (bound at /mnt/bannerlator-sd) with the client - internal storage is
|
||||
# Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is
|
||||
# its main library already - and the ARM64 Proton that lets it launch the Windows ones.
|
||||
# A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts,
|
||||
# so it is in the Compatibility dropdown the first time the user looks. One per line, each the
|
||||
@@ -273,13 +273,24 @@ case $mode in
|
||||
# device it took 5 of 8, leaving out both little cores and the fastest one - which is a
|
||||
# reasonable thing to do while a game is running and the wrong thing when the client is all
|
||||
# there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns
|
||||
# children continuously and each one inherits Steam's choice, not ours.
|
||||
# children continuously and each one inherits Steam's choice, not ours. The beat reads /proc
|
||||
# itself instead of running pgrep, and only runs taskset for a process that is new or has
|
||||
# grown threads since it was last pinned: every fork is a round of traced syscalls, and a
|
||||
# beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because
|
||||
# without it only a process's main thread moves and the renderer's workers stay where they were.
|
||||
if [ -n "${BL_CLIENT_CPUS:-}" ]; then
|
||||
step "client cores: $BL_CLIENT_CPUS"
|
||||
(
|
||||
for _ in $(seq 1 720); do
|
||||
for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do
|
||||
taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
|
||||
declare -A _bl_pinned=()
|
||||
for ((_i = 0; _i < 720; _i++)); do
|
||||
for _d in /proc/[0-9]*; do
|
||||
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
|
||||
case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac
|
||||
_t=("$_d"/task/*)
|
||||
_p=${_d#/proc/}
|
||||
[ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue
|
||||
taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
|
||||
_bl_pinned[$_p]=${#_t[@]}
|
||||
done
|
||||
nap 5
|
||||
done
|
||||
@@ -420,13 +431,33 @@ case $mode in
|
||||
prev=$app
|
||||
done
|
||||
) &
|
||||
# The library beat. Both helpers are whole programs under proot (the compat one a python
|
||||
# start and a parse of the client's config), and nearly every beat has nothing new for them.
|
||||
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
|
||||
# marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it
|
||||
# reads from has changed since its last run - a new manifest, a new depot or tool, the client
|
||||
# rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute
|
||||
# regardless.
|
||||
(
|
||||
beat=0
|
||||
for _ in $(seq 1 720); do
|
||||
compat_stamp=$HOME/.bl-compat-beat
|
||||
rm -f "$compat_stamp"
|
||||
compat_due() {
|
||||
local d
|
||||
[ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0
|
||||
for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \
|
||||
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do
|
||||
[ "$d" -nt "$compat_stamp" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
for ((_i = 0; _i < 720; _i++)); do
|
||||
nap 5
|
||||
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
|
||||
beat=$((beat + 1))
|
||||
if [ "$((beat % 3))" -eq 0 ]; then
|
||||
[ "$((beat % 3))" -eq 0 ] || continue
|
||||
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
|
||||
if compat_due; then
|
||||
: > "$compat_stamp"
|
||||
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
@@ -578,6 +609,24 @@ BUSCONF
|
||||
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
|
||||
games_watcher=$!
|
||||
fi
|
||||
# Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in
|
||||
# client once a session until the answer is yes. From then on the app offers importing
|
||||
# Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again.
|
||||
if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then
|
||||
(
|
||||
owned=$HOME/.config/droiddeck/lossless-owned
|
||||
for ((_i = 0; _i < 60; _i++)); do
|
||||
nap 30
|
||||
[ -e "$owned" ] && exit 0
|
||||
answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue
|
||||
case $answer in
|
||||
*'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;;
|
||||
*) echo "== Lossless Scaling: not owned by this account" ;;
|
||||
esac
|
||||
exit 0
|
||||
done
|
||||
) &
|
||||
fi
|
||||
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
|
||||
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
|
||||
# session can never again loop in silence, which reads as a menu opening and closing by itself.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -13,6 +13,9 @@ import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
STATE = ".droiddeck-games.json"
|
||||
WINNATIVE = ".winnative-steam/steamapps"
|
||||
LIBRARY = "/mnt/droiddeck-sd"
|
||||
LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
BASE_ID = 76561197960265728
|
||||
|
||||
|
||||
@@ -40,6 +43,11 @@ def edition_key(name):
|
||||
return title_key(name)
|
||||
|
||||
|
||||
def legacy_path(path):
|
||||
path = str(path)
|
||||
return LEGACY_LIBRARY + path[len(LIBRARY):] if path == LIBRARY or path.startswith(LIBRARY + "/") else path
|
||||
|
||||
|
||||
def source_stamp(game):
|
||||
try:
|
||||
exe = Path(game["exe"])
|
||||
@@ -53,19 +61,23 @@ def source_stamp(game):
|
||||
return None
|
||||
|
||||
|
||||
def manifest_ids(steamapps, name):
|
||||
matches = []
|
||||
for manifest in Path(steamapps).glob("appmanifest_*.acf"):
|
||||
try:
|
||||
install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I)
|
||||
if install and install[1] == name:
|
||||
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return matches
|
||||
|
||||
|
||||
def identify(game):
|
||||
folder = Path(game["folder"])
|
||||
steamapps = folder.parent.parent
|
||||
if folder.parent.name == "common" and steamapps.name == "steamapps":
|
||||
matches = []
|
||||
for manifest in steamapps.glob("appmanifest_*.acf"):
|
||||
try:
|
||||
text = manifest.read_text()
|
||||
install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I)
|
||||
if install and install[1] == folder.name:
|
||||
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]:
|
||||
matches = manifest_ids(library, folder.name)
|
||||
if matches:
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
ids = set()
|
||||
@@ -128,6 +140,43 @@ def probe(steam_root, ids):
|
||||
method(client, 1, C.c_bool, C.c_int)(client, pipe)
|
||||
|
||||
|
||||
def game_running():
|
||||
"""Whether a Proton game is up: a wineserver is there for as long as any prefix is in use."""
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return False
|
||||
for pid in pids:
|
||||
try:
|
||||
with open("/proc/%s/comm" % pid) as f:
|
||||
if f.read().strip() == "wineserver":
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def downloads_idle(steam_root):
|
||||
try:
|
||||
return not any(Path(steam_root, "steamapps/downloading").iterdir())
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def request_restart(steam_root):
|
||||
"""The client reads manifests and shortcuts only at start, so an import needs one restart;
|
||||
the session's loop runs the importer between the exit and the next start."""
|
||||
Path(os.environ.get("HOME", "/root"), ".bl-restart-once").touch()
|
||||
subprocess.run([str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
|
||||
|
||||
|
||||
def unimported(steam_root, snapshot, candidates):
|
||||
routes = read_json(Path(steam_root) / "userdata" / snapshot["account"] / "config/.droiddeck-routes.json", {})
|
||||
routed = set(routes.values()) if isinstance(routes, dict) else set()
|
||||
return sorted({appid for appid in candidates.values() if snapshot["owned"].get(str(appid)) is True} - routed)
|
||||
|
||||
|
||||
def watch(steam_root, listing):
|
||||
games = read_json(listing, [])
|
||||
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
|
||||
@@ -138,7 +187,11 @@ def watch(steam_root, listing):
|
||||
# The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership
|
||||
# only moves with a purchase or a new match, so it runs when the matches or their sources
|
||||
# change, after a failed probe, and otherwise every five minutes - not on every 30 s beat.
|
||||
probed, next_probe = None, 0
|
||||
# A failed probe is retried on a doubling delay up to those five minutes rather than on every
|
||||
# beat (a client that is not logged on fails it for as long as it stays that way), and none
|
||||
# starts while a game is running: the answer can wait, the game's cores cannot.
|
||||
probed, next_probe, failures = None, 0, 0
|
||||
pending, requested = [], []
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
for game in games:
|
||||
@@ -155,7 +208,9 @@ def watch(steam_root, listing):
|
||||
next_lookup = now + 300
|
||||
status = "waiting for a matching Steam title"
|
||||
key = (dict(candidates), dict(sources))
|
||||
if candidates and (key != probed or now >= next_probe):
|
||||
if candidates and (key != probed or now >= next_probe) and game_running():
|
||||
status = last_status
|
||||
elif candidates and (key != probed or now >= next_probe):
|
||||
probed, next_probe = key, now + 300
|
||||
try:
|
||||
result = subprocess.run([sys.executable, __file__, "probe", steam_root,
|
||||
@@ -163,8 +218,10 @@ def watch(steam_root, listing):
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode))
|
||||
probed = None
|
||||
failures += 1
|
||||
next_probe = now + min(300, 30 * 2 ** failures)
|
||||
else:
|
||||
failures = 0
|
||||
line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP="))
|
||||
snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP="))
|
||||
if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0:
|
||||
@@ -172,18 +229,59 @@ def watch(steam_root, listing):
|
||||
snapshot["candidates"] = candidates
|
||||
snapshot["sources"] = sources
|
||||
save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot)
|
||||
pending = unimported(steam_root, snapshot, candidates)
|
||||
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
|
||||
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
|
||||
status = "ownership check unavailable: " + str(error)
|
||||
probed = None
|
||||
failures += 1
|
||||
next_probe = now + min(300, 30 * 2 ** failures)
|
||||
elif candidates:
|
||||
status = last_status
|
||||
if pending and pending != requested and not game_running() and downloads_idle(steam_root):
|
||||
requested = pending
|
||||
print("bannerlator-steam-games: restarting the client once to import %s" % pending, flush=True)
|
||||
try:
|
||||
request_restart(steam_root)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
print("bannerlator-steam-games: restart unavailable: %s" % error, flush=True)
|
||||
if status != last_status:
|
||||
print("bannerlator-steam-games: " + status, flush=True)
|
||||
last_status = status
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
def installed_build(folder, appid, installdir):
|
||||
"""The build another Steam client recorded for these files, so Steam updates from it
|
||||
instead of verifying every file."""
|
||||
try:
|
||||
text = (folder.parent / WINNATIVE / f"appmanifest_{appid}.acf").read_text()
|
||||
install = re.search(r'("installdir"\s*")([^"\\]*)(")', text, re.I)
|
||||
flags = re.search(r'"StateFlags"\s*"(\d+)"', text)
|
||||
if (install and install[2] == folder.name and flags and flags[1] == "4"
|
||||
and re.search(rf'"appid"\s*"{appid}"', text) and '"InstalledDepots"' in text):
|
||||
return text[:install.start(2)] + installdir + text[install.end(2):]
|
||||
except OSError:
|
||||
pass
|
||||
if not (folder / ".download_complete").is_file():
|
||||
return None
|
||||
try:
|
||||
ids = json.loads((folder / ".DepotDownloader/depot.config").read_text())["installedManifestIDs"]
|
||||
depots = sorted((int(depot), int(gid)) for depot, gid in ids.items())
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
if not depots or not all(0 < depot < 0x80000000 and 0 < gid < 0x7FFFFFFFFFFFFFFF for depot, gid in depots):
|
||||
return None
|
||||
return "".join(f' "{depot}"\n {{\n "manifest" "{gid}"\n }}\n' for depot, gid in depots)
|
||||
|
||||
|
||||
def update_not_started(text, steamapps, appid, installdir):
|
||||
fields = dict(re.findall(r'^\s*"(\w+)"\s*"([^"]*)"', text, re.M))
|
||||
return (fields.get("installdir") == installdir and fields.get("buildid", "0") == "0"
|
||||
and fields.get("BytesDownloaded", "0") == "0" and fields.get("BytesStaged", "0") == "0"
|
||||
and re.search(r'"InstalledDepots"\s*\{\s*\}', text) is not None
|
||||
and not (steamapps / "downloading" / str(appid)).exists())
|
||||
|
||||
|
||||
def seed_install(steam_root, game, appid):
|
||||
steamapps = Path(steam_root) / "steamapps"
|
||||
common = steamapps / "common"
|
||||
@@ -191,16 +289,30 @@ def seed_install(steam_root, game, appid):
|
||||
target = common / name
|
||||
source = Path(game["folder"])
|
||||
title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
|
||||
content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
|
||||
minimal = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
|
||||
+ f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n')
|
||||
legacy = content.replace(' "Universe" "1"\n', '')
|
||||
legacy = minimal.replace(' "Universe" "1"\n', '')
|
||||
build = installed_build(source, appid, name)
|
||||
if build is None:
|
||||
content = minimal
|
||||
elif build.lstrip().startswith('"AppState"'):
|
||||
content = build
|
||||
else:
|
||||
content = minimal[:-2] + ' "InstalledDepots"\n {\n' + build + ' }\n}\n'
|
||||
manifest = steamapps / f"appmanifest_{appid}.acf"
|
||||
for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]:
|
||||
if target.is_symlink() and os.readlink(target) != str(source) and os.readlink(target) == legacy_path(source):
|
||||
relink = target.with_name(name + ".droiddeck")
|
||||
relink.unlink(missing_ok=True)
|
||||
relink.symlink_to(source, target_is_directory=True)
|
||||
os.replace(relink, target)
|
||||
for root in [Path(steam_root), Path(LIBRARY)]:
|
||||
existing = root / "steamapps" / manifest.name
|
||||
if existing.is_file():
|
||||
if (existing == manifest and source.is_dir() and target.is_symlink()
|
||||
and os.readlink(target) == str(source)
|
||||
and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))):
|
||||
and existing.read_text() != content
|
||||
and (existing.read_text() in (minimal, legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))
|
||||
or (build is not None and update_not_started(existing.read_text(), steamapps, appid, name)))):
|
||||
temp = manifest.with_suffix(".droiddeck")
|
||||
temp.write_text(content)
|
||||
os.replace(temp, manifest)
|
||||
@@ -232,11 +344,13 @@ def route(steam_root, acct, games):
|
||||
owned, candidates, sources = {}, {}, {}
|
||||
shortcuts, routes = [], {}
|
||||
for game in games:
|
||||
appid = candidates.get(game.get("folder"))
|
||||
folder = game.get("folder", "")
|
||||
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
|
||||
stamp = source_stamp(game)
|
||||
recorded = sources.get(folder, sources.get(legacy_path(folder)))
|
||||
confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000
|
||||
and owned.get(str(appid)) is True and stamp is not None
|
||||
and sources.get(game.get("folder")) == stamp)
|
||||
and recorded in (stamp, [stamp[0], legacy_path(stamp[1])] + stamp[2:]))
|
||||
if confirmed:
|
||||
try:
|
||||
if seed_install(steam_root, game, appid):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/python3
|
||||
# Registers the card the app installs to (bound at /mnt/bannerlator-sd) as a library folder of the
|
||||
# Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the
|
||||
# native Steam client, so they show as installed there instead of being downloaded a second time.
|
||||
#
|
||||
# bannerlator-steam-library <steam root>
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
# The app binds the storage the user chose (Steam cog -> Game storage) at this path and names it
|
||||
# in BL_LIBRARY_LABEL; nothing bound = internal only, and the entry is removed so the client does
|
||||
# not keep offering a place that is not there.
|
||||
LIBRARIES = (("/mnt/bannerlator-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),)
|
||||
LIBRARIES = (("/mnt/droiddeck-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),)
|
||||
# The client calls its own library "Local Drive (/)", which says nothing about where that is.
|
||||
# It is the phone's internal storage, so say so.
|
||||
MAIN_LABEL = "Internal Storage"
|
||||
@@ -66,6 +66,26 @@ def register(steam_root, library, label):
|
||||
|
||||
|
||||
RETIRED = ("/mnt/bannerlator",)
|
||||
RENAMED = (("/mnt/bannerlator-sd", "/mnt/droiddeck-sd"),)
|
||||
|
||||
|
||||
def rename(steam_root, old, new):
|
||||
"""Moves a library registered under an earlier mount point to the current one, keeping its
|
||||
entry (content id, app sizes) rather than registering it anew."""
|
||||
path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
old_path = re.compile(r'("path"\s+")%s(")' % re.escape(old))
|
||||
if not old_path.search(text):
|
||||
return
|
||||
if re.search(r'"path"\s+"%s"' % re.escape(new), text):
|
||||
forget(steam_root, old)
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text))
|
||||
print("bannerlator-steam-library: moved %s to %s" % (old, new))
|
||||
|
||||
|
||||
def forget(steam_root, library):
|
||||
@@ -152,6 +172,8 @@ def main():
|
||||
steam_root = sys.argv[1]
|
||||
for library in RETIRED:
|
||||
forget(steam_root, library)
|
||||
for old, new in RENAMED:
|
||||
rename(steam_root, old, new)
|
||||
relabel(steam_root, steam_root, MAIN_LABEL)
|
||||
for library, label in LIBRARIES:
|
||||
if os.path.isdir(os.path.join(library, "steamapps")):
|
||||
|
||||
@@ -97,7 +97,7 @@ def write(obj):
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def shortcut(game):
|
||||
def shortcut(game, launch_options=""):
|
||||
appid = int(game["appid"]) & 0xFFFFFFFF
|
||||
return {
|
||||
"appid": struct.unpack("<i", struct.pack("<I", appid))[0],
|
||||
@@ -106,7 +106,7 @@ def shortcut(game):
|
||||
"StartDir": '"%s"' % game["dir"],
|
||||
"icon": (game.get("art") or {}).get("icon", "") or "",
|
||||
"ShortcutPath": "",
|
||||
"LaunchOptions": "",
|
||||
"LaunchOptions": launch_options,
|
||||
"IsHidden": 0,
|
||||
"AllowDesktopConfig": 1,
|
||||
"AllowOverlay": 1,
|
||||
@@ -208,8 +208,22 @@ def main():
|
||||
routing = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(routing)
|
||||
games, routes = routing.route(steam_root, acct, games)
|
||||
# Steam saves Big Picture's per-shortcut launch options in shortcuts.vdf. Keep them when
|
||||
# refreshing our entries, keyed by the appid that stays stable across session starts.
|
||||
managed = {
|
||||
v.get("appid"): v for v in existing.values()
|
||||
if isinstance(v, dict) and TAG in (v.get("tags") or {}).values()
|
||||
}
|
||||
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
|
||||
ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")]
|
||||
ours = []
|
||||
for game in games:
|
||||
if not game.get("exe") or not game.get("name"):
|
||||
continue
|
||||
entry = shortcut(game)
|
||||
previous = managed.get(entry["appid"])
|
||||
if previous and isinstance(previous.get("LaunchOptions"), str):
|
||||
entry["LaunchOptions"] = previous["LaunchOptions"]
|
||||
ours.append(entry)
|
||||
entries = {str(i): v for i, v in enumerate(kept + ours)}
|
||||
tmp = path + ".bannerlator"
|
||||
with open(tmp, "wb") as f:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ def load(name):
|
||||
|
||||
imports = load('bannerlator-steam-games')
|
||||
shortcuts = load('bannerlator-steam-shortcuts')
|
||||
library = load('bannerlator-steam-library')
|
||||
|
||||
|
||||
class ImportsTest(unittest.TestCase):
|
||||
@@ -73,6 +74,7 @@ class ImportsTest(unittest.TestCase):
|
||||
from unittest.mock import Mock
|
||||
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
|
||||
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
|
||||
patch.object(imports, 'request_restart'), \
|
||||
patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \
|
||||
patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \
|
||||
patch.object(imports.subprocess, 'run', return_value=result), \
|
||||
@@ -83,6 +85,35 @@ class ImportsTest(unittest.TestCase):
|
||||
snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {})
|
||||
self.assertTrue(snapshot['owned']['42'])
|
||||
|
||||
def run_watch(self, beats, routes=None, busy=False):
|
||||
listing = self.root / 'games.json'
|
||||
listing.write_text(json.dumps([self.game]))
|
||||
if routes is not None:
|
||||
imports.save_json(self.acct / 'config/.droiddeck-routes.json', routes)
|
||||
if busy:
|
||||
(self.steam / 'steamapps/downloading/7').mkdir(parents=True)
|
||||
from unittest.mock import Mock
|
||||
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
|
||||
with patch.object(imports, 'identify', return_value=42), \
|
||||
patch.object(imports, 'game_running', return_value=False), \
|
||||
patch.object(imports, 'request_restart') as restart, \
|
||||
patch.object(imports.time, 'monotonic', side_effect=list(range(0, 30 * beats, 30))), \
|
||||
patch.object(imports.time, 'sleep', side_effect=[None] * (beats - 1) + [RuntimeError('stop')]), \
|
||||
patch.object(imports.subprocess, 'run', return_value=result), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(RuntimeError, 'stop'):
|
||||
imports.watch(self.steam, listing)
|
||||
return restart
|
||||
|
||||
def test_newly_owned_game_restarts_the_client_once(self):
|
||||
self.assertEqual(1, self.run_watch(3).call_count)
|
||||
|
||||
def test_imported_game_does_not_restart_the_client(self):
|
||||
self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count)
|
||||
|
||||
def test_restart_waits_for_downloads(self):
|
||||
self.assertEqual(0, self.run_watch(2, busy=True).call_count)
|
||||
|
||||
def test_unowned_unknown_and_different_account_stay_shortcuts(self):
|
||||
for owned, account in [(False, '123'), (True, '456')]:
|
||||
self.snapshot(owned, account)
|
||||
@@ -91,6 +122,93 @@ class ImportsTest(unittest.TestCase):
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertFalse((self.steam / 'steamapps').exists())
|
||||
|
||||
def winnative_manifest(self, flags='4', installdir='Example'):
|
||||
path = self.folder.parent / imports.WINNATIVE / 'appmanifest_42.acf'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n'
|
||||
'\t"buildid"\t\t"7"\n\t"InstalledDepots"\n\t{\n\t\t"43"\n\t\t{\n\t\t\t"manifest"\t\t"99"\n'
|
||||
'\t\t\t"size"\t\t"4"\n\t\t}\n\t}\n}\n' % (flags, installdir))
|
||||
return path
|
||||
|
||||
def test_winnative_manifest_carries_installed_build(self):
|
||||
source = self.winnative_manifest()
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
|
||||
self.assertEqual(source.read_text().replace('"Example"', '"DroidDeck-42"'), text)
|
||||
|
||||
def test_winnative_manifest_identifies_folder(self):
|
||||
self.winnative_manifest()
|
||||
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
|
||||
self.assertEqual(42, imports.identify(self.game))
|
||||
|
||||
def test_incomplete_winnative_manifest_is_ignored(self):
|
||||
for flags, installdir in [('1026', 'Example'), ('4', 'Other')]:
|
||||
self.winnative_manifest(flags, installdir)
|
||||
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
|
||||
|
||||
def test_depot_config_records_installed_depots(self):
|
||||
config = self.folder / '.DepotDownloader/depot.config'
|
||||
config.parent.mkdir()
|
||||
config.write_text(json.dumps({'installedManifestIDs': {'43': 99}}))
|
||||
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
|
||||
(self.folder / '.download_complete').touch()
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
|
||||
self.assertIn('"InstalledDepots"\n {\n "43"\n {\n "manifest" "99"', text)
|
||||
self.assertTrue(text.endswith(' }\n}\n'))
|
||||
config.write_text(json.dumps({'installedManifestIDs': {'43': 0x7FFFFFFFFFFFFFFF}}))
|
||||
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
|
||||
|
||||
def test_bare_import_manifest_gains_installed_build(self):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.winnative_manifest()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertIn('"buildid"\t\t"7"', (self.steam / 'steamapps/appmanifest_42.acf').read_text())
|
||||
|
||||
def steam_fresh_install(self, downloaded='0'):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
path = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n'
|
||||
'\t"buildid"\t\t"0"\n\t"BytesDownloaded"\t\t"%s"\n\t"BytesStaged"\t\t"0"\n'
|
||||
'\t"InstalledDepots"\n\t{\n\t}\n}\n' % downloaded)
|
||||
self.winnative_manifest()
|
||||
return path
|
||||
|
||||
def test_queued_fresh_install_takes_installed_build(self):
|
||||
path = self.steam_fresh_install()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertIn('"buildid"\t\t"7"', path.read_text())
|
||||
|
||||
def test_started_fresh_install_is_left_to_steam(self):
|
||||
for downloaded, staging in [('5', False), ('0', True)]:
|
||||
path = self.steam_fresh_install(downloaded)
|
||||
if staging:
|
||||
(self.steam / 'steamapps/downloading/42').mkdir(parents=True)
|
||||
before = path.read_text()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual(before, path.read_text())
|
||||
|
||||
def test_links_and_snapshots_from_the_old_mount_carry_over(self):
|
||||
old = self.root / 'old-mount'
|
||||
with patch.object(imports, 'LIBRARY', str(self.root / 'Games')), \
|
||||
patch.object(imports, 'LEGACY_LIBRARY', str(old)):
|
||||
legacy_game = dict(self.game, folder=str(old / 'Example'), exe=str(old / 'Example/Example.exe'))
|
||||
stamp = imports.source_stamp(self.game)
|
||||
imports.save_json(self.acct / 'config' / imports.STATE,
|
||||
dict(account='123', owned={'42': True}, candidates={legacy_game['folder']: 42},
|
||||
sources={legacy_game['folder']: [stamp[0], legacy_game['exe']] + stamp[2:]}))
|
||||
link = self.steam / 'steamapps/common/DroidDeck-42'
|
||||
link.parent.mkdir(parents=True)
|
||||
link.symlink_to(legacy_game['folder'], target_is_directory=True)
|
||||
games, routes = imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual([], games)
|
||||
self.assertEqual({str(self.game['appid']): 42}, routes)
|
||||
self.assertEqual(str(self.folder), imports.os.readlink(link))
|
||||
|
||||
def test_existing_manifest_is_never_overwritten(self):
|
||||
manifest = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
manifest.parent.mkdir(parents=True)
|
||||
@@ -188,6 +306,34 @@ class ImportsTest(unittest.TestCase):
|
||||
self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text()))
|
||||
|
||||
|
||||
class LibraryRenameTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.steam = Path(temp.name)
|
||||
self.vdf = self.steam / 'steamapps/libraryfolders.vdf'
|
||||
self.vdf.parent.mkdir()
|
||||
|
||||
def entry(self, index, path):
|
||||
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"contentid"\t\t"%d"\n\t}\n' % (index, path, 7 + index)
|
||||
|
||||
def test_old_mount_keeps_its_entry_under_the_new_path(self):
|
||||
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/root') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
|
||||
with patch('builtins.print'):
|
||||
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
|
||||
text = self.vdf.read_text()
|
||||
self.assertIn('"path"\t\t"/mnt/droiddeck-sd"\n\t\t"contentid"\t\t"8"', text)
|
||||
self.assertNotIn('bannerlator-sd', text)
|
||||
|
||||
def test_old_mount_is_dropped_when_new_path_is_registered(self):
|
||||
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/mnt/droiddeck-sd') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
|
||||
with patch('builtins.print'):
|
||||
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
|
||||
text = self.vdf.read_text()
|
||||
self.assertEqual(1, text.count('"path"'))
|
||||
self.assertIn('/mnt/droiddeck-sd', text)
|
||||
|
||||
|
||||
class SteamProbeTest(unittest.TestCase):
|
||||
def test_probe_reads_account_and_licenses_and_releases_handles(self):
|
||||
import ctypes as C
|
||||
|
||||
Reference in new issue
Block a user