mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Merge remote-tracking branch 'droiddeck/main' into feat/refresh-loading-tips
This commit is contained in:
commit
27aec13910
119 files changed
+3571
-255
No files matched your search
@@ -617,12 +617,11 @@ jobs:
|
||||
- name: Check out MaliBridge
|
||||
run: git submodule update --init external/MaliBridge
|
||||
- name: Fetch the Mali (Venus) component (tools/venus/release.env)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. tools/venus/release.env
|
||||
gh release download "${VENUS_TAG}" -R "${VENUS_REPO}" -p venus.tzst -O venus.tzst
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 10 -o venus.tzst \
|
||||
"https://github.com/${VENUS_REPO}/releases/download/${VENUS_TAG}/venus.tzst"
|
||||
echo "${VENUS_SHA256} venus.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf venus.tzst -C app/src/main
|
||||
test -f app/src/main/jniLibs/arm64-v8a/libvirgl_test_server.so
|
||||
|
||||
@@ -277,6 +277,8 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
|
||||
dependsOn(stageSessionScripts)
|
||||
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
|
||||
from(rootProject.file('tools/linuxfs/innoextract'))
|
||||
from(rootProject.file('tools')) { include 'droiddeckctl', 'adb_device.py', 'proot_profile.py'; into('agent/ctl') }
|
||||
from(rootProject.file('docs/agent-control.md')) { into('agent/ctl') }
|
||||
from(layout.buildDirectory.dir('prepared-assets'))
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 /root/.local/share/droiddeck/ctl/droiddeckctl "$@"
|
||||
@@ -1,5 +1,6 @@
|
||||
add_library(droiddeckwayland SHARED
|
||||
src/compositor.c
|
||||
src/clock_domain.c
|
||||
src/vk_present.c
|
||||
src/effects_chain.c
|
||||
src/cardboard.c
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "clock_domain.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static const int64_t *g_shift;
|
||||
static pthread_mutex_t g_control_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static dev_t g_device;
|
||||
static ino_t g_inode;
|
||||
|
||||
void droiddeck_clock_domain_set_control(const char *path) {
|
||||
int fd;
|
||||
struct stat st;
|
||||
pthread_mutex_lock(&g_control_lock);
|
||||
if (!path || !*path) {
|
||||
const int64_t *previous = g_shift;
|
||||
g_shift = NULL;
|
||||
if (previous) munmap((void *)previous, sizeof(*previous));
|
||||
pthread_mutex_unlock(&g_control_lock);
|
||||
return;
|
||||
}
|
||||
fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0 || fstat(fd, &st) != 0 || st.st_size < (off_t)sizeof(int64_t)) {
|
||||
if (fd >= 0) close(fd);
|
||||
pthread_mutex_unlock(&g_control_lock);
|
||||
return;
|
||||
}
|
||||
if (g_shift && st.st_dev == g_device && st.st_ino == g_inode) {
|
||||
close(fd);
|
||||
pthread_mutex_unlock(&g_control_lock);
|
||||
return;
|
||||
}
|
||||
const int64_t *shift = mmap(NULL, sizeof(*shift), PROT_READ, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (shift != MAP_FAILED) {
|
||||
g_device = st.st_dev;
|
||||
g_inode = st.st_ino;
|
||||
const int64_t *previous = g_shift;
|
||||
g_shift = shift;
|
||||
if (previous) munmap((void *)previous, sizeof(*previous));
|
||||
}
|
||||
pthread_mutex_unlock(&g_control_lock);
|
||||
}
|
||||
|
||||
static int64_t shift_ns(void) {
|
||||
pthread_mutex_lock(&g_control_lock);
|
||||
const int64_t *mapping = g_shift;
|
||||
int64_t shift = mapping ? __atomic_load_n(mapping, __ATOMIC_RELAXED) : 0;
|
||||
pthread_mutex_unlock(&g_control_lock);
|
||||
return shift > 0 ? shift : 0;
|
||||
}
|
||||
|
||||
int64_t droiddeck_clock_domain_adjust_ns(int64_t value) {
|
||||
int64_t shift = shift_ns();
|
||||
return value > shift ? value - shift : 0;
|
||||
}
|
||||
|
||||
int64_t droiddeck_clock_domain_now_ns(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return droiddeck_clock_domain_adjust_ns((int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec);
|
||||
}
|
||||
|
||||
uint32_t droiddeck_clock_domain_now_ms(void) {
|
||||
return (uint32_t)(droiddeck_clock_domain_now_ns() / 1000000LL);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#ifndef DROIDDECK_CLOCK_DOMAIN_H
|
||||
#define DROIDDECK_CLOCK_DOMAIN_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
void droiddeck_clock_domain_set_control(const char *path);
|
||||
int64_t droiddeck_clock_domain_now_ns(void);
|
||||
uint32_t droiddeck_clock_domain_now_ms(void);
|
||||
int64_t droiddeck_clock_domain_adjust_ns(int64_t value);
|
||||
|
||||
#endif
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "compositor_internal.h"
|
||||
#include "clock_domain.h"
|
||||
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
|
||||
@@ -727,9 +728,7 @@ static void on_pending_buffer_destroyed(struct wl_listener *l, void *data) {
|
||||
static void fire_frames(struct wl_list *frames) {
|
||||
struct wl_resource *cb, *tmp;
|
||||
uint32_t t;
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
t = (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
|
||||
t = droiddeck_clock_domain_now_ms();
|
||||
wl_resource_for_each_safe(cb, tmp, frames) {
|
||||
wl_callback_send_done(cb, t);
|
||||
wl_resource_destroy(cb);
|
||||
@@ -1061,6 +1060,7 @@ static int64_t presented_ns(int64_t render_ns) {
|
||||
|
||||
static void feedback_present_all(struct wl_list *list, int64_t t) {
|
||||
struct wl_resource *fb, *tmp;
|
||||
t = droiddeck_clock_domain_adjust_ns(t);
|
||||
uint64_t sec = (uint64_t)(t / 1000000000LL);
|
||||
uint32_t nsec = (uint32_t)(t % 1000000000LL);
|
||||
wl_resource_for_each_safe(fb, tmp, list) {
|
||||
@@ -2493,8 +2493,7 @@ static void on_vsync(int64_t frame_time_ns) {
|
||||
|
||||
|
||||
static uint32_t now_ms(void) {
|
||||
struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
|
||||
return droiddeck_clock_domain_now_ms();
|
||||
}
|
||||
|
||||
static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint32_t serial,
|
||||
@@ -3114,12 +3113,10 @@ static void relative_pointers_pointer_gone(struct wl_resource *pointer) {
|
||||
|
||||
static int send_relative_motion(struct wl_client *client, double dx, double dy) {
|
||||
struct relative_pointer *rp;
|
||||
struct timespec ts;
|
||||
uint64_t ut;
|
||||
int sent = 0;
|
||||
if (dx == 0 && dy == 0) return 0;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
ut = (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)ts.tv_nsec / 1000;
|
||||
ut = (uint64_t)(droiddeck_clock_domain_now_ns() / 1000);
|
||||
wl_list_for_each(rp, &g_relative_pointers, link) {
|
||||
if (!rp->pointer || wl_resource_get_client(rp->pointer) != client) continue;
|
||||
zwp_relative_pointer_v1_send_relative_motion(rp->resource, (uint32_t)(ut >> 32), (uint32_t)ut,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "cardboard.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "gpu_clock_pin.h"
|
||||
#include "clock_domain.h"
|
||||
|
||||
extern int droiddeck_wayland_run(void);
|
||||
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
|
||||
@@ -354,6 +355,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, j
|
||||
droiddeck_wayland_vsync((int64_t)frameTimeNanos);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClockControl(JNIEnv *env, jclass clazz, jstring path) {
|
||||
const char *value = path ? (*env)->GetStringUTFChars(env, path, NULL) : NULL;
|
||||
droiddeck_clock_domain_set_control(value);
|
||||
if (value) (*env)->ReleaseStringUTFChars(env, path, value);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv *env, jclass clazz, jboolean hide) {
|
||||
g_hide_shell = hide ? 1 : 0;
|
||||
|
||||
@@ -43,7 +43,8 @@ class App : Application() {
|
||||
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.agent.AgentHost.refresh(this)
|
||||
if (com.droiddeck.launcher.agent.AgentHost.available) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
|
||||
com.droiddeck.launcher.agent.AgentTab.load(this)
|
||||
if (com.droiddeck.launcher.agent.AgentTab.visible) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
|
||||
com.droiddeck.launcher.lab.LabNotice.started(this)
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
|
||||
@@ -593,6 +593,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var vacDiagnostics by mutableStateOf(false)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var graphicsLogs by mutableStateOf(false)
|
||||
private var buildWatermark by mutableStateOf(true)
|
||||
private var steamGpuDrawing by mutableStateOf(false)
|
||||
private var bridgeFellBack by mutableStateOf(false)
|
||||
private var bridgeFellBackMemory by mutableStateOf(false)
|
||||
@@ -1106,7 +1107,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
if (savedInstanceState == null) readIntegrationIntent(intent)
|
||||
readGameIntent(intent)
|
||||
// A Lab build is usually the agent's work, so the app comes back where the agent is.
|
||||
if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentHost.available) navRequest = "agent"
|
||||
if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentTab.visible) navRequest = "agent"
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
@@ -1178,6 +1179,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
|
||||
buildWatermark = buildWatermark,
|
||||
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
|
||||
tpUbwcMode = drivers.tpUbwcModeForDevice(), tpUbwcRestartRequired = drivers.tpUbwcRestartRequired(), canRestartApp = !runtimeChangesBlocked(),
|
||||
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
|
||||
@@ -1416,6 +1418,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setDetailedGraphicsLogs(this, !SessionPrefs.detailedGraphicsLogs(this))
|
||||
graphicsLogs = SessionPrefs.detailedGraphicsLogs(this)
|
||||
},
|
||||
onBuildWatermark = {
|
||||
SessionPrefs.setBuildWatermarkEnabled(this, !SessionPrefs.buildWatermarkEnabled(this))
|
||||
buildWatermark = SessionPrefs.buildWatermarkEnabled(this)
|
||||
com.droiddeck.launcher.ui.BuildWatermark.refresh(this)
|
||||
},
|
||||
onVacDiagnostics = {
|
||||
SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this))
|
||||
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
|
||||
@@ -2867,6 +2874,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
graphicsLogs = SessionPrefs.detailedGraphicsLogs(this)
|
||||
buildWatermark = SessionPrefs.buildWatermarkEnabled(this)
|
||||
steamGpuDrawing = SessionPrefs.steamGpuCompositing(this)
|
||||
bridgeFellBack = SessionPrefs.bridgeFellBack(this)
|
||||
bridgeFellBackMemory = SessionPrefs.bridgeFellBackForMemory(this)
|
||||
@@ -3046,6 +3054,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
|
||||
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
com.droiddeck.launcher.agent.AgentTab.load(this)
|
||||
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
|
||||
}
|
||||
}, "reset-settings").start()
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.OrphanReaper
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
@@ -50,7 +51,12 @@ object AgentHost {
|
||||
@VisibleForTesting
|
||||
internal const val BRIEF = "You run inside DroidDeck on the user's handheld, in its Linux userland (Ubuntu under proot, aarch64), " +
|
||||
"and the user talks to you from DroidDeck's Agent tab, often on a small screen, so keep replies short. " +
|
||||
"droiddeckctl (in the /root/DroidDeck checkout, tools/droiddeckctl) inspects and drives the app over loopback adb (\$ADB_SERIAL). " +
|
||||
"droiddeckctl inspects and drives the app over loopback adb (\$ADB_SERIAL); its guide is ${AgentSetup.CTL}/agent-control.md. " +
|
||||
"Steam, the desktop and games run in DroidDeck's session, a separate guest from yours: a program you start here is not in it and has no screen. " +
|
||||
"Reach the session through droiddeckctl: state, start steam, stop, launch, quit, focus, screenshot, logs, and guest to run a command inside it. " +
|
||||
"Starting, stopping or restarting a session leaves you running. " +
|
||||
"Never kill by a command-line pattern (pkill -f, pgrep -f piped to kill): your own command line holds the user's message, so the pattern matches you. " +
|
||||
"End a game with droiddeckctl quit, the session with droiddeckctl stop, anything else by its pid. " +
|
||||
"dd-lab-build builds the checkout as a Lab build and restarts DroidDeck on it (docs/lab-builds.md). DroidDeck runs the build in a " +
|
||||
"process of its own and shows the user its progress and time left; dd-lab-build follows that build's log until it is done. " +
|
||||
"Run it with run_in_background and keep checking its output, so the user can talk to you meanwhile. " +
|
||||
@@ -252,6 +258,7 @@ object AgentHost {
|
||||
val workdir = workspace(app)
|
||||
Thread({
|
||||
runCatching { current.prepare(LinuxRuntime.rootDir(app)) }
|
||||
runCatching { AgentSetup.installHelpers(app) }
|
||||
val child = try {
|
||||
guest(app, command, workdir).start()
|
||||
} catch (e: Exception) {
|
||||
@@ -260,6 +267,7 @@ object AgentHost {
|
||||
return@Thread
|
||||
}
|
||||
val started = Turn(child, AgentProcesses.pidOf(child))
|
||||
OrphanReaper.keep(started.pid)
|
||||
main.post { if (generation == gen && state == State.WORKING && turn == null) turn = started else AgentProcesses.killTree(started.pid) }
|
||||
child.outputStream.close()
|
||||
var ended = false
|
||||
@@ -278,6 +286,7 @@ object AgentHost {
|
||||
}
|
||||
runCatching { raw?.close() }
|
||||
val status = runCatching { child.waitFor() }.getOrDefault(-1)
|
||||
OrphanReaper.release(started.pid)
|
||||
main.post {
|
||||
if (turn === started) {
|
||||
turn = null
|
||||
@@ -500,9 +509,15 @@ object AgentHost {
|
||||
val child = guest(app, listOf("sh", "-c",
|
||||
"mkdir -p /root/.cache; DD_LAB_DETACHED=1 exec dd-lab-build \"\$@\" > /root/.cache/dd-lab-build.log 2>&1", "dd-lab-build") + args, cwd).start()
|
||||
main.post { labProcess = child }
|
||||
child.outputStream.close()
|
||||
child.inputStream.readBytes()
|
||||
child.waitFor()
|
||||
val pid = AgentProcesses.pidOf(child)
|
||||
OrphanReaper.keep(pid)
|
||||
try {
|
||||
child.outputStream.close()
|
||||
child.inputStream.readBytes()
|
||||
child.waitFor()
|
||||
} finally {
|
||||
OrphanReaper.release(pid)
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "could not run a Lab build", it) }
|
||||
main.post {
|
||||
labProcess = null
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.OrphanReaper
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -39,7 +40,8 @@ object AgentSetup {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private const val REPO = "https://github.com/Droid-Deck/DroidDeck.git"
|
||||
private const val CHECKOUT = "/root/DroidDeck"
|
||||
private val HELPERS = listOf("android-open", "dd-gh-login")
|
||||
private val HELPERS = listOf("android-open", "dd-gh-login", "droiddeckctl")
|
||||
internal const val CTL = "/root/.local/share/droiddeck/ctl"
|
||||
private const val PROBE_TIMEOUT_S = 60L
|
||||
|
||||
fun check(context: Context) {
|
||||
@@ -79,7 +81,8 @@ object AgentSetup {
|
||||
Step.CONNECTION -> return
|
||||
Step.INSTALL -> {
|
||||
listOf("sh", "-c", "set -e; export DEBIAN_FRONTEND=noninteractive; " +
|
||||
"apt-get update; apt-get install -y --no-install-recommends git gh adb curl ca-certificates python3; ${harness.install}")
|
||||
"dpkg --configure -a; apt-get update; apt-get -f install -y; " +
|
||||
"apt-get install -y --no-install-recommends git gh adb curl ca-certificates python3; ${harness.install}")
|
||||
}
|
||||
Step.SIGN_IN -> harness.signIn
|
||||
Step.GITHUB -> listOf("dd-gh-login")
|
||||
@@ -95,12 +98,21 @@ object AgentSetup {
|
||||
installHelpers(app)
|
||||
runCatching { harness.prepare(LinuxRuntime.rootDir(app)) }
|
||||
val child = AgentHost.guest(app, command).start()
|
||||
child.outputStream.close()
|
||||
child.inputStream.bufferedReader().forEachLine { line ->
|
||||
val text = line.trim()
|
||||
if (text.isNotEmpty() && !noise(text)) main.post { detail[step] = text.take(160) }
|
||||
val pid = AgentProcesses.pidOf(child)
|
||||
OrphanReaper.keep(pid)
|
||||
try {
|
||||
child.outputStream.close()
|
||||
logFile(app, step).apply { parentFile?.mkdirs() }.bufferedWriter().use { log ->
|
||||
child.inputStream.bufferedReader().forEachLine { line ->
|
||||
log.appendLine(line)
|
||||
val text = line.trim()
|
||||
if (text.isNotEmpty() && !noise(text)) main.post { detail[step] = text.take(160) }
|
||||
}
|
||||
child.waitFor().also { log.appendLine("exit $it") }
|
||||
}
|
||||
} finally {
|
||||
OrphanReaper.release(pid)
|
||||
}
|
||||
child.waitFor()
|
||||
}
|
||||
main.post {
|
||||
AgentHostService.setupEnded(app)
|
||||
@@ -116,6 +128,9 @@ object AgentSetup {
|
||||
}, "agent-setup-${step.name.lowercase()}").start()
|
||||
}
|
||||
|
||||
/** The whole output of a step's last run; the card shows only its last line. */
|
||||
fun logFile(app: Context, step: Step) = File(app.filesDir, "agent/setup-${step.name.lowercase()}.log")
|
||||
|
||||
fun buildToolsInstalled(app: Context) = AgentHost.guestFileExists(app, "/root/aapt2/aapt2") && AgentHost.guestFileExists(app, "$CHECKOUT/.git")
|
||||
|
||||
/** dd-lab-build compiled this install's commit, so a Lab build compiles only what changed. */
|
||||
@@ -165,12 +180,16 @@ object AgentSetup {
|
||||
}
|
||||
|
||||
/** Written before every step, so the helpers a step relies on are there and match this DroidDeck version. */
|
||||
private fun installHelpers(app: Context) {
|
||||
val bin = File(LinuxRuntime.rootDir(app), "root/.local/bin").apply { mkdirs() }
|
||||
for (name in HELPERS) {
|
||||
val target = File(bin, name)
|
||||
app.assets.open("agent/$name").use { input -> target.outputStream().use { input.copyTo(it) } }
|
||||
target.setExecutable(true, false)
|
||||
}
|
||||
internal fun installHelpers(app: Context) {
|
||||
val root = LinuxRuntime.rootDir(app)
|
||||
val bin = File(root, "root/.local/bin").apply { mkdirs() }
|
||||
for (name in HELPERS) copyAsset(app, "agent/$name", File(bin, name))
|
||||
val ctl = File(root, CTL.removePrefix("/")).apply { mkdirs() }
|
||||
for (name in app.assets.list("agent/ctl").orEmpty()) copyAsset(app, "agent/ctl/$name", File(ctl, name))
|
||||
}
|
||||
|
||||
private fun copyAsset(app: Context, asset: String, target: File) {
|
||||
app.assets.open(asset).use { input -> target.outputStream().use { input.copyTo(it) } }
|
||||
target.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object AgentTab {
|
||||
var shown by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
val visible: Boolean get() = shown && AgentHost.available
|
||||
|
||||
fun load(context: Context) {
|
||||
shown = SessionPrefs.agentTab(context)
|
||||
}
|
||||
|
||||
fun set(context: Context, show: Boolean) {
|
||||
shown = show
|
||||
SessionPrefs.setAgentTab(context, show)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.lab.LabOverlay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -16,6 +17,7 @@ import java.io.FileInputStream
|
||||
internal class AddonPack(
|
||||
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
|
||||
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
|
||||
private val labBuild: Boolean = LabOverlay.active != null,
|
||||
private val bundle: (Context) -> java.io.InputStream = { it.assets.open("packs/$name.tar.zst") },
|
||||
) {
|
||||
sealed interface State {
|
||||
@@ -42,7 +44,8 @@ internal class AddonPack(
|
||||
|
||||
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
|
||||
|
||||
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
|
||||
private fun version(context: Context): String =
|
||||
if (reuseDeviceCopy || labBuild) deviceCopy(context) ?: sha256 else sha256
|
||||
|
||||
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
|
||||
.filter { dir ->
|
||||
|
||||
@@ -13,6 +13,11 @@ object HostProcess {
|
||||
|
||||
@JvmStatic
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?, outputFile: File?): Int {
|
||||
return start(command, env, dir, onExit, onLine, outputFile, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?, outputFile: File?, onOutputComplete: Runnable?): Int {
|
||||
val argv = split(command)
|
||||
if (argv.isEmpty()) return -1
|
||||
return try {
|
||||
@@ -35,6 +40,8 @@ object HostProcess {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines -> lines.forEach { onLine.accept(it) } }
|
||||
} catch (e: Exception) {
|
||||
} finally {
|
||||
onOutputComplete?.run()
|
||||
}
|
||||
}, "proc-out-$pid").apply { priority = Thread.NORM_PRIORITY - 1; isDaemon = true; start() }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.util.Log
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object SessionLogCapture {
|
||||
private const val TAG = "SessionLogCapture"
|
||||
@@ -104,4 +107,53 @@ object SessionLogCapture {
|
||||
Log.w(TAG, "could not dump the crash buffer", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun dumpMemoryPressure(target: File) {
|
||||
var temporary: File? = null
|
||||
try {
|
||||
val staged = File.createTempFile(target.name, ".part", target.parentFile).also { temporary = it }
|
||||
val lines = StringBuilder()
|
||||
val done = java.util.concurrent.CountDownLatch(1)
|
||||
val outputDone = java.util.concurrent.CountDownLatch(1)
|
||||
val exitStatus = AtomicInteger(Int.MIN_VALUE)
|
||||
val pid = HostProcess.start(
|
||||
"/system/bin/logcat -b events -d -v threadtime -t 800", null, target.parentFile,
|
||||
{ status -> exitStatus.set(status); done.countDown() },
|
||||
{ line -> synchronized(lines) { lines.append(line).append('\n') } },
|
||||
null,
|
||||
outputDone::countDown,
|
||||
)
|
||||
val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10)
|
||||
val finished = if (pid == -1) false else {
|
||||
val exitTime = deadline - System.nanoTime()
|
||||
exitTime > 0 && done.await(exitTime, java.util.concurrent.TimeUnit.NANOSECONDS) &&
|
||||
(deadline - System.nanoTime()).let { it > 0 && outputDone.await(it, java.util.concurrent.TimeUnit.NANOSECONDS) }
|
||||
}
|
||||
val body = synchronized(lines) { lines.toString() }
|
||||
val status = exitStatus.get()
|
||||
val pressure = runCatching { File("/proc/pressure/memory").readText() }.getOrDefault("unavailable\n")
|
||||
val meminfo = runCatching { File("/proc/meminfo").readText() }.getOrDefault("unavailable\n")
|
||||
val gpu = runCatching { File("/sys/class/kgsl/kgsl/page_alloc").readText() }.getOrDefault("unavailable\n")
|
||||
staged.writeText(
|
||||
"Android memory events:\n" +
|
||||
when {
|
||||
pid == -1 -> "(logcat could not be started at all)\n"
|
||||
status != Int.MIN_VALUE && status != 0 -> "(logcat failed with exit status $status; no Android memory events were captured)\n" +
|
||||
if (body.isEmpty()) "" else "\nlogcat command output:\n$body"
|
||||
!finished -> if (body.isEmpty()) "(logcat did not finish within 10 s; nothing captured)\n"
|
||||
else body + "(logcat did not finish within 10 s; later events may be missing)\n"
|
||||
body.isNotEmpty() -> body
|
||||
else -> "(no readable events were returned)\n"
|
||||
} +
|
||||
"\n/proc/pressure/memory:\n$pressure\n/proc/meminfo:\n$meminfo\n/sys/class/kgsl/kgsl/page_alloc:\n$gpu"
|
||||
)
|
||||
Files.move(
|
||||
staged.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
temporary?.delete()
|
||||
Log.w(TAG, "could not dump memory pressure diagnostics", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public final class PadBridge {
|
||||
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
|
||||
private final PadState[] playerStates = new PadState[SLOTS];
|
||||
private final float[][] rawSticks = new float[SLOTS][4];
|
||||
private final boolean[][] keyTriggers = new boolean[SLOTS][2];
|
||||
private final int[] retainedSticks = new int[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final PadState homeGuideReport = new PadState();
|
||||
@@ -289,7 +290,10 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public static boolean isControllerKeyEvent(KeyEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
return isControllerKeyEvent(event, event.getDevice());
|
||||
}
|
||||
|
||||
static boolean isControllerKeyEvent(KeyEvent event, InputDevice device) {
|
||||
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
|
||||
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
@@ -314,7 +318,10 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public static boolean isControllerMotionEvent(MotionEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
return isControllerMotionEvent(event, event.getDevice());
|
||||
}
|
||||
|
||||
static boolean isControllerMotionEvent(MotionEvent event, InputDevice device) {
|
||||
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
|
||||
&& !isSystemVirtualInputName(device.getName())
|
||||
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
@@ -375,10 +382,11 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isControllerKeyEvent(event) || excluded.test(event.getDeviceId())) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
InputDevice device = event.getDevice();
|
||||
if (!isControllerKeyEvent(event, device) || excluded.test(event.getDeviceId())) return false;
|
||||
int slot = slotFor(device);
|
||||
if (slot < 0) return false;
|
||||
int side = JoyCon.side(event.getDevice());
|
||||
int side = JoyCon.side(device);
|
||||
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
@@ -400,18 +408,18 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
case KeyEvent.KEYCODE_MOVE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Z:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.R4 : DeckControls.L4, pressed);
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(device) ? DeckControls.R4 : DeckControls.L4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(device) ? DeckControls.L4 : DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2:
|
||||
pad.leftTrigger = keyTrigger(pad.leftTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE));
|
||||
analogTrigger(device, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE), keyTriggers[slot], 0);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2:
|
||||
pad.rightTrigger = keyTrigger(pad.rightTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS));
|
||||
analogTrigger(device, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS), keyTriggers[slot], 1);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
@@ -419,7 +427,7 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
noteDevice(event.getDevice(), slot);
|
||||
noteDevice(device, slot);
|
||||
if (pressed && event.getRepeatCount() == 0 && !event.isCanceled()) noteChordInput(slot);
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
@@ -471,6 +479,7 @@ public final class PadBridge {
|
||||
stateFor(slot).clear();
|
||||
retainedSticks[slot] = 0;
|
||||
Arrays.fill(rawSticks[slot], 0f);
|
||||
Arrays.fill(keyTriggers[slot], false);
|
||||
}
|
||||
|
||||
private void mutateState(java.util.function.Consumer<PadState> mutation) {
|
||||
@@ -489,16 +498,16 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isControllerMotionEvent(event) || excluded.test(event.getDeviceId())) return false;
|
||||
InputDevice device = event.getDevice();
|
||||
if (!isControllerMotionEvent(event, device) || excluded.test(event.getDeviceId())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
int slot = slotFor(device);
|
||||
if (slot < 0) return false;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
noteDevice(device, slot);
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
InputDevice device = event.getDevice();
|
||||
int side = JoyCon.side(device);
|
||||
if (side >= 0) {
|
||||
int[] stick = JoyCon.stickAxes(side, device, event.getSource());
|
||||
@@ -515,7 +524,7 @@ public final class PadBridge {
|
||||
}
|
||||
float lx = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float ly = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
boolean wired = wiredXboxLayout(event);
|
||||
boolean wired = wiredXboxLayout(device, event.getSource());
|
||||
float rx = event.getAxisValue(wired ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
|
||||
float ry = event.getAxisValue(wired ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
|
||||
retainStick(slot, JoyCon.LEFT, lx, ly);
|
||||
@@ -525,11 +534,11 @@ public final class PadBridge {
|
||||
float lt = 0f;
|
||||
float rt = 0f;
|
||||
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
|
||||
lt = leftTrigger(event, wired);
|
||||
lt = axisTrigger(leftTrigger(event, wired), keyTriggers[slot], 0);
|
||||
pad.leftTrigger = lt;
|
||||
}
|
||||
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
|
||||
rt = rightTrigger(event, wired);
|
||||
rt = axisTrigger(rightTrigger(event, wired), keyTriggers[slot], 1);
|
||||
pad.rightTrigger = rt;
|
||||
}
|
||||
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
|
||||
@@ -553,9 +562,27 @@ public final class PadBridge {
|
||||
return true;
|
||||
}
|
||||
|
||||
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog) {
|
||||
if (analog || repeatCount > 0) return current;
|
||||
return pressed ? 1f : 0f;
|
||||
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog, boolean[] held, int side) {
|
||||
if (repeatCount > 0) return current;
|
||||
if (!analog) {
|
||||
held[side] = pressed;
|
||||
return pressed ? 1f : 0f;
|
||||
}
|
||||
if (pressed) {
|
||||
if (current != 0f) return current;
|
||||
held[side] = true;
|
||||
return 1f;
|
||||
}
|
||||
if (!held[side]) return current;
|
||||
held[side] = false;
|
||||
return 0f;
|
||||
}
|
||||
|
||||
static float axisTrigger(float value, boolean[] held, int side) {
|
||||
if (!held[side]) return value;
|
||||
if (value == 0f) return 1f;
|
||||
held[side] = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
static boolean analogTrigger(InputDevice device, int... axes) {
|
||||
@@ -987,7 +1014,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private boolean isPrimary(KeyEvent event) {
|
||||
int id = event.getDevice().getId();
|
||||
int id = event.getDeviceId();
|
||||
int first = firstPlayerSlot();
|
||||
return owners[first] == id || partners[first] == id;
|
||||
}
|
||||
|
||||
@@ -232,11 +232,24 @@ object BattleNet {
|
||||
}
|
||||
|
||||
internal fun hostFolder(context: Context, path: String): File? {
|
||||
val drive = Regex("""^([CcZz]):[\\/](.*)$""").matchEntire(path.trim()) ?: return null
|
||||
val drive = Regex("""^([A-Za-z]):[\\/](.*)$""").matchEntire(path.trim()) ?: return null
|
||||
val relative = drive.groupValues[2].replace('\\', '/').trim('/')
|
||||
if (relative.isEmpty() || relative.split('/').any { it == ".." }) return null
|
||||
return if (drive.groupValues[1].equals("c", ignoreCase = true)) File(prefix(context), "pfx/drive_c/$relative")
|
||||
else com.droiddeck.launcher.frontend.AddedGames.hostPath(context, "/$relative")
|
||||
val letter = drive.groupValues[1].lowercase()
|
||||
if (letter == "c") return File(prefix(context), "pfx/drive_c/$relative")
|
||||
val link = File(prefix(context), "pfx/dosdevices/$letter:").toPath()
|
||||
val mapped = runCatching { java.nio.file.Files.readSymbolicLink(link).toString() }.getOrNull()
|
||||
val target = when {
|
||||
mapped == null -> if (letter == "z") "/" else return null
|
||||
mapped.startsWith("/") -> mapped
|
||||
else -> {
|
||||
val rootfs = LinuxRuntime.rootDir(context).toPath().toAbsolutePath().normalize()
|
||||
val resolved = link.toAbsolutePath().parent.resolve(mapped).normalize()
|
||||
if (!resolved.startsWith(rootfs)) return null
|
||||
"/" + rootfs.relativize(resolved).joinToString("/")
|
||||
}
|
||||
}.trimEnd('/')
|
||||
return com.droiddeck.launcher.frontend.AddedGames.hostPath(context, "$target/$relative")
|
||||
}
|
||||
|
||||
private val KEY = Regex("""^\[(.+)\](?:\s+\d+)?\s*$""")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AtomicFile
|
||||
import java.io.File
|
||||
|
||||
internal object BuildWatermarkSetting {
|
||||
const val CHANGED = "com.droiddeck.launcher.BUILD_WATERMARK_CHANGED"
|
||||
|
||||
private fun file(context: Context) = AtomicFile(File(context.filesDir, "build-watermark"))
|
||||
|
||||
private val processLock = Any()
|
||||
|
||||
private fun <T> locked(context: Context, action: () -> T): T = synchronized(processLock) {
|
||||
File(context.filesDir, "build-watermark.lock").outputStream().channel.use { channel ->
|
||||
channel.lock().use { action() }
|
||||
}
|
||||
}
|
||||
|
||||
fun enabled(context: Context): Boolean = try {
|
||||
locked(context) {
|
||||
// SharedPreferences caches are process-local; read the shared file on every refresh.
|
||||
file(context).readFully().decodeToString() != "off"
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).getBoolean("buildWatermark", true)
|
||||
}
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
locked(context) {
|
||||
val target = file(context)
|
||||
val stream = target.startWrite()
|
||||
try {
|
||||
stream.write((if (on) "on" else "off").toByteArray())
|
||||
target.finishWrite(stream)
|
||||
} catch (error: Throwable) {
|
||||
target.failWrite(stream)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
context.sendBroadcast(Intent(CHANGED).setPackage(context.packageName))
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,11 @@ object GameStorage {
|
||||
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val added = SteamLibraries.list(context).map { canonical(it.host) }
|
||||
val card = effective(context)?.let { SteamLibraries.canonical(File(it.path)) }
|
||||
val added = SteamLibraries.list(context).map { SteamLibraries.canonical(it.host) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
val host = SteamLibraries.canonical(root.host)
|
||||
if (!hasInstalledGames(root.host)) return@mapNotNull null
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
@@ -109,8 +109,6 @@ object GameStorage {
|
||||
internal fun hasInstalledGames(root: File): Boolean =
|
||||
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
fun labelFor(context: Context, path: String): String =
|
||||
options(context).firstOrNull { it.path == path }?.name
|
||||
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
|
||||
@@ -11,6 +11,9 @@ object OrphanReaper {
|
||||
|
||||
fun release(pid: Int) { kept.remove(pid) }
|
||||
|
||||
/** Kept processes and everything they started, which session cleanups must leave running. */
|
||||
fun spared(): Set<Int> = descendants(kept)
|
||||
|
||||
fun hasOtherProcesses(): Boolean = hasOtherProcesses(
|
||||
File("/proc").list()?.mapNotNull { it.toIntOrNull() }, android.os.Process.myPid(),
|
||||
) { pid ->
|
||||
@@ -30,7 +33,7 @@ object OrphanReaper {
|
||||
fun reap(reason: String, matching: (String) -> Boolean = { true }): Int {
|
||||
val me = android.os.Process.myPid()
|
||||
val uid = android.os.Process.myUid()
|
||||
val spared = descendants(kept)
|
||||
val spared = spared()
|
||||
var killed = 0
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
|
||||
@@ -77,6 +77,7 @@ object SessionArtifacts {
|
||||
copySteamLogs(context, dir, record, steamArch)
|
||||
VacDiagnostics.collect(context, dir)
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
SessionLogCapture.dumpMemoryPressure(File(dir, "memory-pressure.log"))
|
||||
val scrubbed = markScrubbed(scrubTree(record))
|
||||
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
|
||||
if (scrubbed) File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
|
||||
@@ -594,6 +595,10 @@ object SessionArtifacts {
|
||||
if (!crashLog.exists()) {
|
||||
SessionLogCapture.dumpCrashBuffer(crashLog)
|
||||
}
|
||||
val memoryLog = File(dir, "memory-pressure.log")
|
||||
if (newest && !memoryLog.exists()) {
|
||||
SessionLogCapture.dumpMemoryPressure(memoryLog)
|
||||
}
|
||||
if (stopWhenSessionStarts && SessionState.running) {
|
||||
record.failed = true
|
||||
record.deferred = true
|
||||
|
||||
@@ -253,6 +253,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("railLabels", show).apply()
|
||||
}
|
||||
|
||||
fun agentTab(context: Context): Boolean = prefs(context).getBoolean("agentTab", true)
|
||||
|
||||
fun setAgentTab(context: Context, show: Boolean) {
|
||||
prefs(context).edit().putBoolean("agentTab", show).apply()
|
||||
}
|
||||
|
||||
fun libraryShowUninstalled(context: Context): Boolean = prefs(context).getBoolean("libraryShowUninstalled", false)
|
||||
|
||||
fun setLibraryShowUninstalled(context: Context, show: Boolean) {
|
||||
@@ -1048,6 +1054,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("vac_diagnostics", on).apply()
|
||||
}
|
||||
|
||||
fun buildWatermarkEnabled(context: Context): Boolean =
|
||||
BuildWatermarkSetting.enabled(context)
|
||||
|
||||
fun setBuildWatermarkEnabled(context: Context, on: Boolean) {
|
||||
BuildWatermarkSetting.setEnabled(context, on)
|
||||
}
|
||||
|
||||
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
|
||||
|
||||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||||
|
||||
@@ -822,6 +822,7 @@ class SessionService : Service() {
|
||||
},
|
||||
zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"),
|
||||
clockControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-clock"),
|
||||
mapClockControl = { WaylandCompositor.nativeSetClockControl(it.path) },
|
||||
pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis ->
|
||||
Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " +
|
||||
"resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB")
|
||||
@@ -1606,10 +1607,11 @@ class SessionService : Service() {
|
||||
val me = android.os.Process.myPid()
|
||||
val rootfs = try { File(filesDir, "linuxfs").canonicalPath } catch (e: Exception) { return } + "/"
|
||||
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
|
||||
val spared = OrphanReaper.spared()
|
||||
var killed = 0
|
||||
for (proc in procs) {
|
||||
val pid = proc.name.toIntOrNull() ?: continue
|
||||
if (pid == me) continue
|
||||
if (pid == me || pid in spared) continue
|
||||
val exe = try { File(proc, "exe").canonicalPath } catch (e: Exception) { continue }
|
||||
if (!exe.startsWith(rootfs)) continue
|
||||
android.os.Process.killProcess(pid)
|
||||
@@ -1621,10 +1623,11 @@ class SessionService : Service() {
|
||||
private fun killStragglers() {
|
||||
val me = android.os.Process.myPid()
|
||||
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
|
||||
val spared = OrphanReaper.spared()
|
||||
var killed = 0
|
||||
for (proc in procs) {
|
||||
val pid = proc.name.toIntOrNull() ?: continue
|
||||
if (pid == me) continue
|
||||
if (pid == me || pid in spared) continue
|
||||
val cmdline = try {
|
||||
File(proc, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ')
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -23,6 +23,7 @@ internal class SessionSuspendController(
|
||||
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
|
||||
private val zramControl: File? = null,
|
||||
private val clockControl: File? = null,
|
||||
private val mapClockControl: (File) -> Unit = {},
|
||||
private val nowNanos: () -> Long = System::nanoTime,
|
||||
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
|
||||
{ _, _, _, _, _, _ -> },
|
||||
@@ -154,6 +155,7 @@ internal class SessionSuspendController(
|
||||
frozenSince = null
|
||||
val control = clockControl?.takeIf { it.isFile && it.length() >= Long.SIZE_BYTES } ?: return
|
||||
try {
|
||||
mapClockControl(control)
|
||||
RandomAccessFile(control, "rw").use { file ->
|
||||
val bytes = ByteArray(Long.SIZE_BYTES)
|
||||
file.readFully(bytes)
|
||||
|
||||
@@ -18,12 +18,12 @@ object SettingsReset {
|
||||
"storesInstallLanguage",
|
||||
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
|
||||
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
|
||||
"stretch16x9", "picturePosition", "railLabels", "pcKeyboardSize", "pcKeyboardOpacity", "pcKeyboardHiddenKeys",
|
||||
"stretch16x9", "picturePosition", "railLabels", "agentTab", "pcKeyboardSize", "pcKeyboardOpacity", "pcKeyboardHiddenKeys",
|
||||
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
|
||||
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "nativeUserland", "cursorComposite",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "enhancedDiagnosticsNext", "storageDiagnostics",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "enhancedDiagnosticsNext", "storageDiagnostics", "buildWatermark",
|
||||
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
|
||||
"bootMovieApplyNext", "bootMovieSteamSeen",
|
||||
@@ -43,6 +43,7 @@ object SettingsReset {
|
||||
var ok = true
|
||||
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
|
||||
BuildWatermarkSetting.setEnabled(context, true)
|
||||
ControllerPrefs.resetAll(context)
|
||||
InputProfiles.resetAll(context)
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
|
||||
@@ -82,13 +82,14 @@ object SteamLibraries {
|
||||
/** Reads storage; call off the main thread. */
|
||||
fun found(context: Context): List<Found> {
|
||||
val entries = list(context)
|
||||
val taken = taken(context, entries) + AddedGames.roots(context).map { canonical(it.host) }
|
||||
val taken = taken(context, entries) + GameStorage.gamesFolderLibraries(context).map { canonical(it.host) }
|
||||
val gamesFolders = AddedGames.roots(context).map { canonical(it.host) }
|
||||
val hits = ArrayList<Pair<File, Int>>()
|
||||
for (root in StorageRoots.list(context)) {
|
||||
if (!root.readable) continue
|
||||
for (dir in candidates(rawView(root.dir), SCAN_DEPTH)) {
|
||||
val games = gameCount(dir)
|
||||
if (games > 0 && File(dir, "steamapps").canWrite() && taken.none { overlaps(it, canonical(dir)) }) hits += dir to games
|
||||
if (games > 0 && File(dir, "steamapps").canWrite() && offered(taken, gamesFolders, canonical(dir))) hits += dir to games
|
||||
}
|
||||
}
|
||||
val picked = ArrayList<Pair<File, Int>>()
|
||||
@@ -101,6 +102,9 @@ object SteamLibraries {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun offered(taken: List<String>, gamesFolders: List<String>, dir: String): Boolean =
|
||||
taken.none { overlaps(it, dir) } && gamesFolders.none { it != dir && overlaps(it, dir) }
|
||||
|
||||
/** The FUSE view under /storage hides files other apps wrote unless DroidDeck has all files access; the raw card mount does not. */
|
||||
private fun rawView(dir: File): File {
|
||||
if (!dir.path.startsWith("/storage/") || dir.path.startsWith("/storage/emulated")) return dir
|
||||
@@ -171,7 +175,7 @@ object SteamLibraries {
|
||||
|
||||
internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/")
|
||||
|
||||
private fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
internal fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
|
||||
private const val SCAN_DEPTH = 2
|
||||
private const val SCAN_LIMIT = 4000
|
||||
|
||||
@@ -11,6 +11,8 @@ object SteamStorage {
|
||||
const val PROTON_11_ARM = 4628740
|
||||
const val REDISTRIBUTABLES = 228980
|
||||
const val FEX = 3127680
|
||||
const val EAC_RUNTIME = 1826330
|
||||
const val BATTLEYE_RUNTIME = 1161040
|
||||
|
||||
const val ARM_TOOL = "droiddeck-proton-arm64"
|
||||
const val ARM_11_TOOL = "droiddeck-proton-11-arm64"
|
||||
@@ -18,7 +20,7 @@ object SteamStorage {
|
||||
private val VALVE_TOOLS = setOf(
|
||||
858280, 961940, 1054830, 1113280, 1245040, 1420170, 1493710, 1580130, 1887720, 2180100, 2348590,
|
||||
2805730, 3029110, 3658110, 4183110, 4628710, 4690330,
|
||||
SCOUT, SOLDIER, SNIPER, ARM_RUNTIME, PROTON_ARM, PROTON_11_ARM, REDISTRIBUTABLES, FEX,
|
||||
SCOUT, SOLDIER, SNIPER, ARM_RUNTIME, PROTON_ARM, PROTON_11_ARM, REDISTRIBUTABLES, FEX, EAC_RUNTIME, BATTLEYE_RUNTIME,
|
||||
)
|
||||
|
||||
private val APP_ID = Regex("^\\s*\"appid\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
@@ -93,6 +95,7 @@ object SteamStorage {
|
||||
val dir = m.installDir.orEmpty()
|
||||
return when {
|
||||
m.appId == SCOUT || m.appId == SOLDIER || dir == "SteamLinuxRuntime" || dir == "SteamLinuxRuntime_soldier" -> Kind.OLD_RUNTIME
|
||||
m.appId == EAC_RUNTIME || m.appId == BATTLEYE_RUNTIME -> Kind.REQUIRED
|
||||
dir.startsWith("Proton") && "ARM64" in dir -> Kind.ARM_PROTON
|
||||
m.appId == PROTON_ARM || m.appId == PROTON_11_ARM -> Kind.ARM_PROTON
|
||||
dir.startsWith("Proton ") -> Kind.X86_PROTON
|
||||
|
||||
@@ -26,7 +26,7 @@ class GameUsage(
|
||||
}
|
||||
|
||||
enum class ToolState { IN_USE, REQUIRED, UNUSED }
|
||||
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN, X64_CLIENT }
|
||||
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, ANTICHEAT_RUNTIME, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN, X64_CLIENT }
|
||||
|
||||
class ToolUsage(
|
||||
val key: String, val name: String, val bytes: Long, val state: ToolState, val reason: ToolReason,
|
||||
@@ -153,6 +153,7 @@ object StorageScan {
|
||||
SteamStorage.ARM_RUNTIME -> ToolReason.ARM_RUNTIME
|
||||
SteamStorage.REDISTRIBUTABLES -> ToolReason.REDISTRIBUTABLES
|
||||
SteamStorage.FEX -> ToolReason.FEX
|
||||
SteamStorage.EAC_RUNTIME, SteamStorage.BATTLEYE_RUNTIME -> ToolReason.ANTICHEAT_RUNTIME
|
||||
else -> ToolReason.STEAM_TOOL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,13 +175,29 @@ object EaOoa {
|
||||
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
|
||||
}
|
||||
|
||||
private const val AUTH_ATTEMPTS = 4
|
||||
|
||||
internal fun tokenRejected(body: String) =
|
||||
body.contains("cause=\"AUTHENTICATION_FAILED\"") && body.contains("field=\"ea_eadmtoken\"")
|
||||
|
||||
internal fun retryTokenRejected(request: () -> EaReply): EaReply {
|
||||
var reply = request()
|
||||
var attempt = 1
|
||||
while (!reply.ok && attempt < AUTH_ATTEMPTS && tokenRejected(reply.text)) {
|
||||
attempt++
|
||||
reply = request()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
|
||||
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
|
||||
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
|
||||
if (requestToken != null) params += "requestToken" to requestToken
|
||||
if (requestType != null) params += "requestType" to requestType
|
||||
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
|
||||
val headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction")
|
||||
val reply = retryTokenRejected { EaHttp.request(url, headers = headers) }
|
||||
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
|
||||
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
|
||||
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
|
||||
|
||||
@@ -65,13 +65,19 @@ internal object GogFiles {
|
||||
fun dir(folder: File): File = inside(folder, ".droiddeck-gog").apply { mkdirs() }
|
||||
fun journal(folder: File): File = File(dir(folder), "operation.json")
|
||||
|
||||
fun inside(root: File, raw: String): File {
|
||||
private fun parts(raw: String): List<String> {
|
||||
val path = raw.replace('\\', '/')
|
||||
val parts = path.split('/')
|
||||
require(path.isNotBlank() && !path.startsWith('/') && ':' !in path && '\u0000' !in path &&
|
||||
path.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "Invalid GOG path: $raw" }
|
||||
parts.none { it.isEmpty() || it == "." || it == ".." }) { "Invalid GOG path: $raw" }
|
||||
return parts
|
||||
}
|
||||
|
||||
fun inside(root: File, raw: String): File {
|
||||
val parts = parts(raw)
|
||||
val base = root.canonicalFile
|
||||
var out = base
|
||||
for (part in path.split('/')) {
|
||||
for (part in parts) {
|
||||
out = File(out, part)
|
||||
require(!Files.isSymbolicLink(out.toPath())) { "GOG path is a symbolic link: $raw" }
|
||||
}
|
||||
@@ -122,21 +128,28 @@ internal object GogFiles {
|
||||
}
|
||||
|
||||
fun validate(files: JSONArray, folder: File) {
|
||||
val base = folder.canonicalFile
|
||||
val paths = HashSet<String>()
|
||||
val components = HashMap<String, String>()
|
||||
val listings = HashMap<File, Map<String, List<String>>>()
|
||||
for (file in files.objects()) {
|
||||
val path = file.getString("path").replace('\\', '/')
|
||||
inside(folder, path)
|
||||
val parts = parts(path)
|
||||
require(!path.startsWith(".droiddeck-", true) && path != StoreGameSidecar.FILE_NAME) { "GOG content overlaps launcher metadata" }
|
||||
require(paths.add(path.lowercase(Locale.ROOT))) { "GOG paths differ only by case: $path" }
|
||||
var parent = folder
|
||||
var parent = base
|
||||
var relative = ""
|
||||
for (part in path.split('/')) {
|
||||
for (part in parts) {
|
||||
relative = if (relative.isEmpty()) part else "$relative/$part"
|
||||
val child = File(parent, part)
|
||||
val previous = components.putIfAbsent(relative.lowercase(Locale.ROOT), relative)
|
||||
require(previous == null || previous == relative) { "GOG directory paths differ only by case: $path" }
|
||||
require(parent.list()?.none { it != part && it.equals(part, true) } != false) { "An existing path differs only by case: $path" }
|
||||
parent = File(parent, part)
|
||||
if (previous == null) {
|
||||
require(!Files.isSymbolicLink(child.toPath())) { "GOG path is a symbolic link: $path" }
|
||||
val existing = listings.getOrPut(parent) { parent.list().orEmpty().groupBy { it.lowercase(Locale.ROOT) } }
|
||||
require(existing[part.lowercase(Locale.ROOT)].orEmpty().all { it == part }) { "An existing path differs only by case: $path" }
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.droiddeck.launcher.session.BuildWatermarkSetting
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
@@ -14,11 +19,14 @@ import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import java.util.WeakHashMap
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.QuestXrActivity
|
||||
|
||||
object BuildWatermark {
|
||||
private const val TITLE_LIMIT = 80
|
||||
private val activities = WeakHashMap<Activity, Unit>()
|
||||
internal var lineProvider: () -> List<String>? = { lines() }
|
||||
|
||||
fun lines(
|
||||
pr: Int = BuildConfig.PR_NUMBER,
|
||||
@@ -38,33 +46,56 @@ object BuildWatermark {
|
||||
if (text.length <= TITLE_LIMIT) text else text.take(TITLE_LIMIT - 1).trimEnd() + "…"
|
||||
|
||||
fun install(app: Application) {
|
||||
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
|
||||
val lines = (listOfNotNull(lab) + lines().orEmpty()).ifEmpty { return }
|
||||
ContextCompat.registerReceiver(
|
||||
app,
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) { refresh(context) }
|
||||
},
|
||||
IntentFilter(BuildWatermarkSetting.CHANGED),
|
||||
"${app.packageName}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
||||
null,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
if (activity is QuestXrActivity) return
|
||||
val decor = activity.window.decorView as? ViewGroup ?: return
|
||||
if (decor.findViewWithTag<View>(TAG) != null) return
|
||||
val margin = (8 * activity.resources.displayMetrics.density).toInt()
|
||||
decor.addView(
|
||||
MarkView(activity, lines).apply { tag = TAG },
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM or Gravity.END,
|
||||
).apply { setMargins(margin, margin, margin, margin) },
|
||||
)
|
||||
synchronized(activities) { activities[activity] = Unit }
|
||||
update(activity)
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
override fun onActivityResumed(activity: Activity) { update(activity) }
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivityStopped(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) { synchronized(activities) { activities.remove(activity) } }
|
||||
})
|
||||
}
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val current = synchronized(activities) { activities.keys.toList() }
|
||||
current.forEach(::update)
|
||||
}
|
||||
|
||||
private fun update(activity: Activity) {
|
||||
if (activity is QuestXrActivity) return
|
||||
val decor = activity.window.decorView as? ViewGroup ?: return
|
||||
decor.findViewWithTag<View>(TAG)?.let(decor::removeView)
|
||||
if (!com.droiddeck.launcher.session.SessionPrefs.buildWatermarkEnabled(activity)) return
|
||||
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
|
||||
val build = lineProvider().orEmpty()
|
||||
val content = (listOfNotNull(lab) + build).ifEmpty { return }
|
||||
val margin = (8 * activity.resources.displayMetrics.density).toInt()
|
||||
decor.addView(
|
||||
MarkView(activity, content).apply { tag = TAG },
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM or Gravity.END,
|
||||
).apply { setMargins(margin, margin, margin, margin) },
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAG = "build-watermark"
|
||||
|
||||
private class MarkView(context: Context, private val lines: List<String>) : View(context) {
|
||||
|
||||
@@ -125,7 +125,7 @@ internal fun navigationKeys(s: FrontEndState) = buildList {
|
||||
add("steam"); add("games")
|
||||
if (s.gameStoresEnabled) add("stores")
|
||||
add("desktop")
|
||||
if (com.droiddeck.launcher.agent.AgentHost.available) add("agent")
|
||||
if (com.droiddeck.launcher.agent.AgentTab.visible) add("agent")
|
||||
add("components")
|
||||
if (s.isHomeApp) add("android-apps")
|
||||
add("setup"); add("updates")
|
||||
|
||||
@@ -108,6 +108,7 @@ class FrontEndState(
|
||||
val vacDiagnostics: Boolean = false,
|
||||
val agentCommands: Boolean = false,
|
||||
val graphicsLogs: Boolean = false,
|
||||
val buildWatermark: Boolean = true,
|
||||
val steamGpuDrawing: Boolean = false,
|
||||
val bridgeFellBack: Boolean = false,
|
||||
val bridgeFellBackMemory: Boolean = false,
|
||||
@@ -247,6 +248,7 @@ class FrontEndActions(
|
||||
val onVacDiagnostics: () -> Unit = {},
|
||||
val onAgentCommands: () -> Unit = {},
|
||||
val onGraphicsLogs: () -> Unit = {},
|
||||
val onBuildWatermark: () -> Unit = {},
|
||||
val onSteamGpuDrawing: () -> Unit = {},
|
||||
val onClearCachedApps: (Boolean) -> Unit = {},
|
||||
val onMemoryHud: (String) -> Unit = {},
|
||||
|
||||
@@ -455,6 +455,11 @@ internal fun SetupPanel(
|
||||
val railContext = androidx.compose.ui.platform.LocalContext.current
|
||||
remember { RailLabels.load(railContext) }
|
||||
ToggleRow(host, "rail-labels", stringResource(R.string.setup_rail_labels), stringResource(R.string.setup_rail_labels_hint), RailLabels.shown) { RailLabels.set(railContext, it) }
|
||||
if (com.droiddeck.launcher.agent.AgentHost.available) {
|
||||
ToggleRow(host, "agent-tab", stringResource(R.string.setup_agent_tab), null, com.droiddeck.launcher.agent.AgentTab.shown) {
|
||||
com.droiddeck.launcher.agent.AgentTab.set(railContext, it)
|
||||
}
|
||||
}
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
ToggleRow(host, "show-stores", stringResource(R.string.setup_stores), null, s.gameStoresEnabled, onChange = a.onGameStoresEnabled)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ internal enum class SessionDrawerPage(val firstControl: String) {
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPages(
|
||||
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
|
||||
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed && com.droiddeck.launcher.agent.AgentTab.shown,
|
||||
): List<SessionDrawerPage> =
|
||||
SessionDrawerPage.entries.filter {
|
||||
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
|
||||
|
||||
@@ -714,6 +714,7 @@ private fun toolDetail(t: ToolUsage): String = when (t.reason) {
|
||||
ToolReason.ARM_RUNTIME -> stringResource(R.string.storage_tool_arm_runtime)
|
||||
ToolReason.REDISTRIBUTABLES -> stringResource(R.string.storage_tool_redist)
|
||||
ToolReason.FEX -> stringResource(R.string.storage_tool_fex)
|
||||
ToolReason.ANTICHEAT_RUNTIME -> stringResource(R.string.storage_tool_anticheat)
|
||||
ToolReason.STEAM_TOOL -> stringResource(R.string.storage_tool_steam)
|
||||
ToolReason.OLD_RUNTIME -> stringResource(R.string.storage_tool_old_runtime)
|
||||
ToolReason.X86_PROTON -> stringResource(R.string.storage_tool_x86_proton)
|
||||
|
||||
@@ -92,6 +92,7 @@ class UpdatesActions(
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val host = rememberMenuHost()
|
||||
val u = s.updates
|
||||
val ua = a.updates
|
||||
val me = remember { AppUpdates.installed() }
|
||||
@@ -128,6 +129,12 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(top = 18.dp)) {
|
||||
ToggleRow(
|
||||
host, "build-watermark", stringResource(R.string.setup_build_watermark),
|
||||
stringResource(R.string.setup_build_watermark_hint), s.buildWatermark,
|
||||
) { a.onBuildWatermark() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +454,18 @@ internal fun AdbTextField(
|
||||
}
|
||||
}
|
||||
showSoftInputOnFocus = !bottomTyping
|
||||
// B on a controller puts the keyboard away first; with no keyboard up it goes back as usual.
|
||||
var hidingKeyboard = false
|
||||
setOnKeyListener { view, keyCode, event ->
|
||||
if (keyCode != android.view.KeyEvent.KEYCODE_BUTTON_B) return@setOnKeyListener false
|
||||
if (event.action == android.view.KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
hidingKeyboard = androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true
|
||||
if (hidingKeyboard) (view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
|
||||
?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
hidingKeyboard
|
||||
}
|
||||
setOnClickListener { if (hasFocus() && !typeOnBottom(this) && bottomTypingLatest.value) showKeyboardHere(this) }
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
focused = hasFocus
|
||||
|
||||
@@ -50,6 +50,7 @@ object CompositorHost {
|
||||
refreshHz: Float,
|
||||
fpsLimit: Int,
|
||||
): Boolean {
|
||||
WaylandCompositor.nativeSetClockControl(java.io.File(context.filesDir, "${com.droiddeck.launcher.runtime.LinuxRuntime.DIR}/tmp/.bl-clock").path)
|
||||
ownVsyncThread = SessionPrefs.vsyncThread(context)
|
||||
WaylandCompositor.nativeSetRenderOnCommit(SessionPrefs.renderOnCommit(context))
|
||||
pace(refreshHz, fpsLimit)
|
||||
@@ -135,7 +136,9 @@ object CompositorHost {
|
||||
vsyncRunning = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun newSession() {
|
||||
WaylandCompositor.nativeSetClockControl(null)
|
||||
if (started) WaylandCompositor.nativeResetFirstFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,8 @@ public final class WaylandCompositor {
|
||||
|
||||
public static native void nativeVsync(long frameTimeNanos);
|
||||
|
||||
public static native void nativeSetClockControl(String path);
|
||||
|
||||
public static native int nativeCompositorTid();
|
||||
|
||||
private static native String nativeCompositorFault();
|
||||
|
||||
@@ -2694,6 +2694,8 @@
|
||||
<string name="comp_this_device">هذا الجهاز</string>
|
||||
<string name="proton_remove_title">إزالة %1$s؟</string>
|
||||
<string name="setup_tab_diagnostics">التشخيص</string>
|
||||
<string name="setup_build_watermark">علامة مائية لاسم الإصدار</string>
|
||||
<string name="setup_build_watermark_hint">عرض اسم إصدار الاختبار أو Lab على الشاشة. مفعّل افتراضيًا.</string>
|
||||
<string name="setup_ready">جاهز للّعب</string>
|
||||
<string name="setup_not_ready">غير جاهز للّعب بعد</string>
|
||||
<string name="setup_ready_runtime">بيئة التشغيل %1$s</string>
|
||||
|
||||
@@ -2631,6 +2631,8 @@
|
||||
<string name="comp_this_device">Este dispositivo</string>
|
||||
<string name="proton_remove_title">¿Quitar %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnóstico</string>
|
||||
<string name="setup_build_watermark">Marca de agua de compilación</string>
|
||||
<string name="setup_build_watermark_hint">Muestra en pantalla la etiqueta de la versión de prueba o Lab. Activada de forma predeterminada.</string>
|
||||
<string name="setup_ready">Listo para jugar</string>
|
||||
<string name="setup_not_ready">Aún no está listo para jugar</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Това устройство</string>
|
||||
<string name="proton_remove_title">Премахване на %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Диагностика</string>
|
||||
<string name="setup_build_watermark">Воден знак с етикет на компилацията</string>
|
||||
<string name="setup_build_watermark_hint">Показва етикета на тестовата или Lab компилация на екрана. Включено по подразбиране.</string>
|
||||
<string name="setup_ready">Готово за игра</string>
|
||||
<string name="setup_not_ready">Още не е готово за игра</string>
|
||||
<string name="setup_ready_runtime">среда %1$s</string>
|
||||
|
||||
@@ -2640,6 +2640,8 @@
|
||||
<string name="comp_this_device">Toto zařízení</string>
|
||||
<string name="proton_remove_title">Odstranit %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostika</string>
|
||||
<string name="setup_build_watermark">Vodoznak s označením sestavení</string>
|
||||
<string name="setup_build_watermark_hint">Zobrazit na obrazovce označení testovacího sestavení nebo sestavení Lab. Ve výchozím nastavení zapnuté.</string>
|
||||
<string name="setup_ready">Připraveno ke hraní</string>
|
||||
<string name="setup_not_ready">Ještě není připraveno ke hraní</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Denne enhed</string>
|
||||
<string name="proton_remove_title">Fjern %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostik</string>
|
||||
<string name="setup_build_watermark">Vandmærke med buildmærkat</string>
|
||||
<string name="setup_build_watermark_hint">Vis mærkatet for test- eller Lab-builden på skærmen. Aktiveret som standard.</string>
|
||||
<string name="setup_ready">Klar til at spille</string>
|
||||
<string name="setup_not_ready">Ikke klar til at spille endnu</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2575,6 +2575,8 @@
|
||||
<string name="comp_this_device">Dieses Gerät</string>
|
||||
<string name="proton_remove_title">%1$s entfernen?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnose</string>
|
||||
<string name="setup_build_watermark">Build-Kennzeichnung als Wasserzeichen</string>
|
||||
<string name="setup_build_watermark_hint">Kennzeichnung des Test- oder Lab-Builds auf dem Bildschirm anzeigen. Standardmäßig aktiviert.</string>
|
||||
<string name="setup_ready">Bereit zum Spielen</string>
|
||||
<string name="setup_not_ready">Noch nicht bereit zum Spielen</string>
|
||||
<string name="setup_ready_runtime">Laufzeit %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Αυτή η συσκευή</string>
|
||||
<string name="proton_remove_title">Κατάργηση %1$s;</string>
|
||||
<string name="setup_tab_diagnostics">Διαγνωστικά</string>
|
||||
<string name="setup_build_watermark">Υδατογράφημα ετικέτας έκδοσης</string>
|
||||
<string name="setup_build_watermark_hint">Εμφάνιση της ετικέτας δοκιμαστικής έκδοσης ή έκδοσης Lab στην οθόνη. Ενεργό από προεπιλογή.</string>
|
||||
<string name="setup_ready">Έτοιμο για παιχνίδι</string>
|
||||
<string name="setup_not_ready">Δεν είναι ακόμα έτοιμο για παιχνίδι</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2610,6 +2610,8 @@
|
||||
<string name="comp_this_device">Este dispositivo</string>
|
||||
<string name="proton_remove_title">¿Quitar %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnóstico</string>
|
||||
<string name="setup_build_watermark">Marca de agua de compilación</string>
|
||||
<string name="setup_build_watermark_hint">Muestra en pantalla la etiqueta de la versión de prueba o Lab. Activada de forma predeterminada.</string>
|
||||
<string name="setup_ready">Listo para jugar</string>
|
||||
<string name="setup_not_ready">Aún no está listo para jugar</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Tämä laite</string>
|
||||
<string name="proton_remove_title">Poistetaanko %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostiikka</string>
|
||||
<string name="setup_build_watermark">Koontiversion vesileima</string>
|
||||
<string name="setup_build_watermark_hint">Näytä testi- tai Lab-version tunniste näytöllä. Käytössä oletusarvoisesti.</string>
|
||||
<string name="setup_ready">Valmis pelattavaksi</string>
|
||||
<string name="setup_not_ready">Ei vielä valmis pelattavaksi</string>
|
||||
<string name="setup_ready_runtime">ajoympäristö %1$s</string>
|
||||
|
||||
@@ -2610,6 +2610,8 @@
|
||||
<string name="comp_this_device">Cet appareil</string>
|
||||
<string name="proton_remove_title">Supprimer %1$s ?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostic</string>
|
||||
<string name="setup_build_watermark">Filigrane de version</string>
|
||||
<string name="setup_build_watermark_hint">Afficher le libellé de la version de test ou Lab à l’écran. Activé par défaut.</string>
|
||||
<string name="setup_ready">Prêt à jouer</string>
|
||||
<string name="setup_not_ready">Pas encore prêt à jouer</string>
|
||||
<string name="setup_ready_runtime">environnement d\'exécution %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">यह डिवाइस</string>
|
||||
<string name="proton_remove_title">%1$s हटाएं?</string>
|
||||
<string name="setup_tab_diagnostics">डायग्नोस्टिक्स</string>
|
||||
<string name="setup_build_watermark">बिल्ड लेबल वॉटरमार्क</string>
|
||||
<string name="setup_build_watermark_hint">स्क्रीन पर टेस्ट या Lab बिल्ड लेबल दिखाएँ। डिफ़ॉल्ट रूप से चालू।</string>
|
||||
<string name="setup_ready">खेलने के लिए तैयार</string>
|
||||
<string name="setup_not_ready">अभी खेलने के लिए तैयार नहीं</string>
|
||||
<string name="setup_ready_runtime">रनटाइम %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Ez a készülék</string>
|
||||
<string name="proton_remove_title">Eltávolítja a %1$s-t?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnosztika</string>
|
||||
<string name="setup_build_watermark">Buildcímke vízjelként</string>
|
||||
<string name="setup_build_watermark_hint">A tesztbuild vagy Lab-build címkéjének megjelenítése a képernyőn. Alapértelmezés szerint bekapcsolva.</string>
|
||||
<string name="setup_ready">Készen áll a játékra</string>
|
||||
<string name="setup_not_ready">Még nem áll készen a játékra</string>
|
||||
<string name="setup_ready_runtime">futtatókörnyezet: %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">Perangkat ini</string>
|
||||
<string name="proton_remove_title">Hapus %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostik</string>
|
||||
<string name="setup_build_watermark">Tanda air label build</string>
|
||||
<string name="setup_build_watermark_hint">Tampilkan label build pengujian atau Lab di layar. Aktif secara default.</string>
|
||||
<string name="setup_ready">Siap bermain</string>
|
||||
<string name="setup_not_ready">Belum siap bermain</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2602,6 +2602,8 @@
|
||||
<string name="comp_this_device">Questo dispositivo</string>
|
||||
<string name="proton_remove_title">Rimuovere %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostica</string>
|
||||
<string name="setup_build_watermark">Filigrana con etichetta build</string>
|
||||
<string name="setup_build_watermark_hint">Mostra sullo schermo l’etichetta della build di test o Lab. Attiva per impostazione predefinita.</string>
|
||||
<string name="setup_ready">Pronto per giocare</string>
|
||||
<string name="setup_not_ready">Non ancora pronto per giocare</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">このデバイス</string>
|
||||
<string name="proton_remove_title">%1$sを削除しますか?</string>
|
||||
<string name="setup_tab_diagnostics">診断</string>
|
||||
<string name="setup_build_watermark">ビルドラベルの透かし</string>
|
||||
<string name="setup_build_watermark_hint">テストビルドまたはLabビルドのラベルを画面に表示します。デフォルトで有効です。</string>
|
||||
<string name="setup_ready">プレイの準備完了</string>
|
||||
<string name="setup_not_ready">まだプレイの準備ができていません</string>
|
||||
<string name="setup_ready_runtime">実行環境 %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">이 기기</string>
|
||||
<string name="proton_remove_title">%1$s을(를) 제거하시겠습니까?</string>
|
||||
<string name="setup_tab_diagnostics">진단</string>
|
||||
<string name="setup_build_watermark">빌드 라벨 워터마크</string>
|
||||
<string name="setup_build_watermark_hint">테스트 또는 Lab 빌드 라벨을 화면에 표시합니다. 기본적으로 켜져 있습니다.</string>
|
||||
<string name="setup_ready">플레이 준비 완료</string>
|
||||
<string name="setup_not_ready">아직 플레이할 준비가 되지 않음</string>
|
||||
<string name="setup_ready_runtime">런타임 %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">Peranti ini</string>
|
||||
<string name="proton_remove_title">Alih keluar %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostik</string>
|
||||
<string name="setup_build_watermark">Tera air label binaan</string>
|
||||
<string name="setup_build_watermark_hint">Paparkan label binaan ujian atau Lab pada skrin. Dihidupkan secara lalai.</string>
|
||||
<string name="setup_ready">Sedia untuk bermain</string>
|
||||
<string name="setup_not_ready">Belum sedia untuk bermain</string>
|
||||
<string name="setup_ready_runtime">masa jalan %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Denne enheten</string>
|
||||
<string name="proton_remove_title">Fjerne %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostikk</string>
|
||||
<string name="setup_build_watermark">Vannmerke for byggetikett</string>
|
||||
<string name="setup_build_watermark_hint">Vis etiketten for test- eller Lab-bygget på skjermen. På som standard.</string>
|
||||
<string name="setup_ready">Klar til å spille</string>
|
||||
<string name="setup_not_ready">Ikke klar til å spille ennå</string>
|
||||
<string name="setup_ready_runtime">kjøretid %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Dit apparaat</string>
|
||||
<string name="proton_remove_title">%1$s verwijderen?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostiek</string>
|
||||
<string name="setup_build_watermark">Buildlabel als watermerk</string>
|
||||
<string name="setup_build_watermark_hint">Toon het label van de testbuild of Lab-build op het scherm. Standaard ingeschakeld.</string>
|
||||
<string name="setup_ready">Klaar om te spelen</string>
|
||||
<string name="setup_not_ready">Nog niet klaar om te spelen</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2640,6 +2640,8 @@
|
||||
<string name="comp_this_device">To urządzenie</string>
|
||||
<string name="proton_remove_title">Usunąć %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostyka</string>
|
||||
<string name="setup_build_watermark">Znak wodny z etykietą kompilacji</string>
|
||||
<string name="setup_build_watermark_hint">Pokazuj na ekranie etykietę kompilacji testowej lub Lab. Domyślnie włączone.</string>
|
||||
<string name="setup_ready">Gotowe do gry</string>
|
||||
<string name="setup_not_ready">Jeszcze nie gotowe do gry</string>
|
||||
<string name="setup_ready_runtime">środowisko %1$s</string>
|
||||
|
||||
@@ -2602,6 +2602,8 @@
|
||||
<string name="comp_this_device">Este dispositivo</string>
|
||||
<string name="proton_remove_title">Remover %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnóstico</string>
|
||||
<string name="setup_build_watermark">Marca de água da compilação</string>
|
||||
<string name="setup_build_watermark_hint">Mostrar no ecrã a etiqueta da compilação de teste ou Lab. Ativada por predefinição.</string>
|
||||
<string name="setup_ready">Pronto para jogar</string>
|
||||
<string name="setup_not_ready">Ainda não está pronto para jogar</string>
|
||||
<string name="setup_ready_runtime">ambiente de execução %1$s</string>
|
||||
|
||||
@@ -2602,6 +2602,8 @@
|
||||
<string name="comp_this_device">Este aparelho</string>
|
||||
<string name="proton_remove_title">Remover %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnóstico</string>
|
||||
<string name="setup_build_watermark">Marca de água da compilação</string>
|
||||
<string name="setup_build_watermark_hint">Mostrar na tela a etiqueta da compilação de teste ou Lab. Ativada por padrão.</string>
|
||||
<string name="setup_ready">Pronto para jogar</string>
|
||||
<string name="setup_not_ready">Ainda não está pronto para jogar</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2613,6 +2613,8 @@
|
||||
<string name="comp_this_device">Acest dispozitiv</string>
|
||||
<string name="proton_remove_title">Elimini %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnosticare</string>
|
||||
<string name="setup_build_watermark">Filigrană cu eticheta versiunii</string>
|
||||
<string name="setup_build_watermark_hint">Afișează pe ecran eticheta versiunii de testare sau Lab. Activată implicit.</string>
|
||||
<string name="setup_ready">Pregătit de joc</string>
|
||||
<string name="setup_not_ready">Încă nepregătit de joc</string>
|
||||
<string name="setup_ready_runtime">mediu de execuție %1$s</string>
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
<string name="comp_this_device">Это устройство</string>
|
||||
<string name="proton_remove_title">Удалить %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Диагностика</string>
|
||||
<string name="setup_build_watermark">Водяной знак с меткой сборки</string>
|
||||
<string name="setup_build_watermark_hint">Показывать на экране метку тестовой сборки или сборки Lab. Включено по умолчанию.</string>
|
||||
<string name="setup_ready">Готово к игре</string>
|
||||
<string name="setup_not_ready">Пока не готово к игре</string>
|
||||
<string name="setup_ready_runtime">среда выполнения %1$s</string>
|
||||
|
||||
@@ -2586,6 +2586,8 @@
|
||||
<string name="comp_this_device">Den här enheten</string>
|
||||
<string name="proton_remove_title">Ta bort %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Diagnostik</string>
|
||||
<string name="setup_build_watermark">Vattenstämpel med byggetikett</string>
|
||||
<string name="setup_build_watermark_hint">Visa testbyggets eller Lab-byggets etikett på skärmen. Aktiverad som standard.</string>
|
||||
<string name="setup_ready">Redo att spela</string>
|
||||
<string name="setup_not_ready">Inte redo att spela än</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">อุปกรณ์นี้</string>
|
||||
<string name="proton_remove_title">ลบ %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">การวินิจฉัย</string>
|
||||
<string name="setup_build_watermark">ลายน้ำป้ายชื่อบิลด์</string>
|
||||
<string name="setup_build_watermark_hint">แสดงป้ายชื่อบิลด์ทดสอบหรือบิลด์ Lab บนหน้าจอ เปิดไว้โดยค่าเริ่มต้น</string>
|
||||
<string name="setup_ready">พร้อมเล่นแล้ว</string>
|
||||
<string name="setup_not_ready">ยังไม่พร้อมเล่น</string>
|
||||
<string name="setup_ready_runtime">รันไทม์ %1$s</string>
|
||||
|
||||
@@ -2575,6 +2575,8 @@
|
||||
<string name="comp_this_device">Bu cihaz</string>
|
||||
<string name="proton_remove_title">%1$s kaldırılsın mı?</string>
|
||||
<string name="setup_tab_diagnostics">Tanılama</string>
|
||||
<string name="setup_build_watermark">Derleme etiketi filigranı</string>
|
||||
<string name="setup_build_watermark_hint">Test veya Lab derlemesinin etiketini ekranda gösterir. Varsayılan olarak açıktır.</string>
|
||||
<string name="setup_ready">Oynamaya hazır</string>
|
||||
<string name="setup_not_ready">Henüz oynamaya hazır değil</string>
|
||||
<string name="setup_ready_runtime">çalışma ortamı %1$s</string>
|
||||
|
||||
@@ -2640,6 +2640,8 @@
|
||||
<string name="comp_this_device">Цей пристрій</string>
|
||||
<string name="proton_remove_title">Видалити %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Діагностика</string>
|
||||
<string name="setup_build_watermark">Водяний знак із міткою збірки</string>
|
||||
<string name="setup_build_watermark_hint">Показувати на екрані мітку тестової збірки або збірки Lab. Увімкнено за замовчуванням.</string>
|
||||
<string name="setup_ready">Готово до гри</string>
|
||||
<string name="setup_not_ready">Ще не готово до гри</string>
|
||||
<string name="setup_ready_runtime">середовище %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">Thiết bị này</string>
|
||||
<string name="proton_remove_title">Xóa %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">Chẩn đoán</string>
|
||||
<string name="setup_build_watermark">Hình mờ nhãn bản dựng</string>
|
||||
<string name="setup_build_watermark_hint">Hiển thị nhãn bản dựng thử nghiệm hoặc Lab trên màn hình. Được bật theo mặc định.</string>
|
||||
<string name="setup_ready">Sẵn sàng để chơi</string>
|
||||
<string name="setup_not_ready">Chưa sẵn sàng để chơi</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
|
||||
@@ -2572,6 +2572,8 @@
|
||||
<string name="gpu_use_title">使用 %1$s?</string>
|
||||
<string name="proton_remove_title">移除 %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">诊断</string>
|
||||
<string name="setup_build_watermark">构建标签水印</string>
|
||||
<string name="setup_build_watermark_hint">在屏幕上显示测试版或 Lab 构建标签。默认启用。</string>
|
||||
<string name="setup_ready">可以游玩了</string>
|
||||
<string name="setup_not_ready">暂时还不能游玩</string>
|
||||
<string name="setup_ready_runtime">运行时 %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">此裝置</string>
|
||||
<string name="proton_remove_title">移除 %1$s?</string>
|
||||
<string name="setup_tab_diagnostics">診斷</string>
|
||||
<string name="setup_build_watermark">建置標籤浮水印</string>
|
||||
<string name="setup_build_watermark_hint">在畫面上顯示測試版或 Lab 建置標籤。預設啟用。</string>
|
||||
<string name="setup_ready">可以開始遊戲</string>
|
||||
<string name="setup_not_ready">尚未可以開始遊戲</string>
|
||||
<string name="setup_ready_runtime">運行環境 %1$s</string>
|
||||
|
||||
@@ -2559,6 +2559,8 @@
|
||||
<string name="comp_this_device">此裝置</string>
|
||||
<string name="proton_remove_title">要移除 %1$s 嗎?</string>
|
||||
<string name="setup_tab_diagnostics">診斷</string>
|
||||
<string name="setup_build_watermark">組建標籤浮水印</string>
|
||||
<string name="setup_build_watermark_hint">在畫面上顯示測試版或 Lab 組建標籤。預設啟用。</string>
|
||||
<string name="setup_ready">已可開始遊戲</string>
|
||||
<string name="setup_not_ready">尚未準備好開始遊戲</string>
|
||||
<string name="setup_ready_runtime">執行環境 %1$s</string>
|
||||
|
||||
@@ -939,6 +939,8 @@
|
||||
<string name="setup_logs_hint">Saved after each session</string>
|
||||
<string name="setup_enhanced_diagnostics">Enhanced diagnostics for next session</string>
|
||||
<string name="setup_enhanced_diagnostics_hint">Captures detailed logs for one session. May reduce performance and use more storage. Turns itself off when the session starts; review logs before sharing.</string>
|
||||
<string name="setup_build_watermark">Build label watermark</string>
|
||||
<string name="setup_build_watermark_hint">Show the test or Lab build label on screen. On by default.</string>
|
||||
<string name="diagnostics_share_title">Share diagnostic bundles</string>
|
||||
<string name="diagnostics_share_session">Session and device</string>
|
||||
<string name="diagnostics_share_graphics">Graphics</string>
|
||||
@@ -2659,6 +2661,7 @@
|
||||
<string name="stores_sort">Sort</string>
|
||||
<string name="setup_rail_labels">Sidebar labels</string>
|
||||
<string name="setup_rail_labels_hint">Show names under the sidebar icons. Turn off for a slimmer sidebar.</string>
|
||||
<string name="setup_agent_tab">Agent tab</string>
|
||||
<string name="library_display">Display</string>
|
||||
<string name="library_display_larger">Larger cards</string>
|
||||
<string name="library_display_smaller">Smaller cards</string>
|
||||
@@ -3357,6 +3360,7 @@
|
||||
<string name="storage_tool_redist">VC++ and DirectX installers games ask for</string>
|
||||
<string name="storage_tool_fex">Runs Linux x86 games</string>
|
||||
<string name="storage_tool_steam">Steam tool</string>
|
||||
<string name="storage_tool_anticheat">Steam downloads this for games that ship anti-cheat, even when it is off. Removing it stalls their install at 0%</string>
|
||||
<string name="storage_tool_old_runtime">DroidDeck runs games without this runtime</string>
|
||||
<string name="storage_tool_x86_proton">x86 Proton, which DroidDeck cannot run</string>
|
||||
<string name="storage_tool_custom_default">Default for every game</string>
|
||||
|
||||
@@ -58,6 +58,22 @@ class AddonPackTest {
|
||||
assertFalse(AddonPack("sample", "", 0L, listOf("tool"), reuseDeviceCopy = false).installed(context))
|
||||
}
|
||||
|
||||
@Test fun labBuildReusesInstalledPackDespiteDifferentSourcePin() {
|
||||
installed("b".repeat(64))
|
||||
val pack = AddonPack("sample", "a".repeat(64), 1L, listOf("tool"), labBuild = true)
|
||||
|
||||
assertTrue(pack.installed(context))
|
||||
assertEquals("b".repeat(64), pack.dir(context).name)
|
||||
}
|
||||
|
||||
@Test fun labBuildWithoutCompleteInstalledPackKeepsSourcePin() {
|
||||
installed("b".repeat(64), complete = false)
|
||||
val pack = AddonPack("sample", "a".repeat(64), 1L, listOf("tool"), labBuild = true)
|
||||
|
||||
assertFalse(pack.installed(context))
|
||||
assertEquals("a".repeat(64), pack.dir(context).name)
|
||||
}
|
||||
|
||||
@Test fun localBuildInstallsTheBundledPackWithoutDownloading() {
|
||||
val archive = tarZst("tool" to "x")
|
||||
val pack = AddonPack("sample", Hashes.sha256(archive), archive.length(), listOf("tool"), bundle = { archive.inputStream() })
|
||||
|
||||
@@ -147,6 +147,75 @@ class JoyConPadTest {
|
||||
assertNull(RecordingWriter.pads[2])
|
||||
}
|
||||
|
||||
@Test fun aControllerThatDisconnectsWhileItsKeyIsHandledIsIgnored() {
|
||||
for (lookups in 0..4) {
|
||||
val event = key(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH)
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = lookups
|
||||
try {
|
||||
val handled = bridge.onKeyEvent(event)
|
||||
if (lookups == 0) assertFalse(handled)
|
||||
if (lookups >= 2) assertTrue(handled)
|
||||
} finally { PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = -1 }
|
||||
}
|
||||
assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH))
|
||||
}
|
||||
|
||||
@Test fun aControllerThatDisconnectsAfterTheFirstLookupStillDeliversItsEvents() {
|
||||
val device = controller(0x045e, 0x0b13, MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
|
||||
val down = key(device, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH)
|
||||
val up = key(device, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH, KeyEvent.ACTION_UP)
|
||||
val move = motion(device, MotionEvent.AXIS_X to 0.5f, MotionEvent.AXIS_Y to -0.25f)
|
||||
try {
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = 1
|
||||
assertTrue(bridge.onKeyEvent(down))
|
||||
assertEquals(0, PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft)
|
||||
assertTrue(pad(0).isDown(PadState.A))
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = 1
|
||||
assertTrue(bridge.onKeyEvent(up))
|
||||
assertFalse(pad(0).isDown(PadState.A))
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = 1
|
||||
assertTrue(bridge.onMotionEvent(move))
|
||||
assertEquals(0, PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft)
|
||||
assertEquals(0.5f, pad(0).leftX)
|
||||
assertEquals(-0.25f, pad(0).leftY)
|
||||
} finally {
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = -1
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = -1
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun nonControllerInputStaysUnhandledWithOneLookup() {
|
||||
val keyboard = InputDeviceBuilder.newBuilder().setId(nextId++).setName("Keyboard").setDescriptor("keyboard")
|
||||
.setExternal(true).setSources(InputDevice.SOURCE_KEYBOARD)
|
||||
.setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC)
|
||||
.setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)).build().also(::register)
|
||||
val typed = KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A, 0, 0, keyboard.id, 30, 0, InputDevice.SOURCE_KEYBOARD)
|
||||
val touch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1, arrayOf(MotionEvent.PointerProperties().apply { id = 0 }),
|
||||
arrayOf(MotionEvent.PointerCoords().apply { x = 10f; y = 20f }), 0, 0, 1f, 1f, keyboard.id, 0, InputDevice.SOURCE_TOUCHSCREEN, 0)
|
||||
try {
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = 1
|
||||
assertFalse(bridge.onKeyEvent(typed))
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = 1
|
||||
assertFalse(bridge.onMotionEvent(touch))
|
||||
} finally {
|
||||
PadBridgeRoutingTest.RegisteredKeyEvent.lookupsLeft = -1
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = -1
|
||||
}
|
||||
assertNull(RecordingWriter.pads[0])
|
||||
}
|
||||
|
||||
@Test fun wiredMotionKeepsItsAxesWhenDeviceDisappears() {
|
||||
val device = controller(0x20d6, 0x2063, MotionEvent.AXIS_RX, MotionEvent.AXIS_RY, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
|
||||
val event = motion(device, MotionEvent.AXIS_RX to 0.75f, MotionEvent.AXIS_RY to -0.5f,
|
||||
MotionEvent.AXIS_Z to -1f, MotionEvent.AXIS_RZ to 1f)
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = 2
|
||||
try { assertTrue(bridge.onMotionEvent(event)) } finally { PadBridgeRoutingTest.RegisteredMotionEvent.lookupsLeft = -1 }
|
||||
assertEquals(0.75f, pad(0).rightX)
|
||||
assertEquals(-0.5f, pad(0).rightY)
|
||||
assertEquals(0f, pad(0).leftTrigger)
|
||||
assertEquals(1f, pad(0).rightTrigger)
|
||||
}
|
||||
|
||||
@Test fun joyConsHeldForVrAreLeftAlone() {
|
||||
bridge.setExcluded { it == left.id }
|
||||
assertFalse(bridge.onKeyEvent(key(left, KeyEvent.KEYCODE_BUTTON_L1, BTN_TL)))
|
||||
|
||||
@@ -191,12 +191,24 @@ class PadBridgeRoutingTest {
|
||||
@Implements(KeyEvent::class)
|
||||
class RegisteredKeyEvent : ShadowKeyEvent() {
|
||||
@RealObject private lateinit var event: KeyEvent
|
||||
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
@Implementation override fun getDevice(): InputDevice? {
|
||||
if (lookupsLeft == 0) return null
|
||||
if (lookupsLeft > 0) lookupsLeft--
|
||||
return Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
}
|
||||
|
||||
companion object { @JvmField var lookupsLeft = -1 }
|
||||
}
|
||||
|
||||
@Implements(MotionEvent::class)
|
||||
class RegisteredMotionEvent : ShadowMotionEvent() {
|
||||
@RealObject private lateinit var event: MotionEvent
|
||||
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
@Implementation override fun getDevice(): InputDevice? {
|
||||
if (lookupsLeft == 0) return null
|
||||
if (lookupsLeft > 0) lookupsLeft--
|
||||
return Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
}
|
||||
|
||||
companion object { @JvmField var lookupsLeft = -1 }
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,11 @@ class PadPartialAxesTest {
|
||||
|
||||
@Test fun wiredXboxTriggerKeysDoNotOverrideTheirAxes() {
|
||||
val device = device(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, wired = true)
|
||||
motion(device, MotionEvent.AXIS_Z to 0f, MotionEvent.AXIS_RZ to -1f)
|
||||
motion(device, MotionEvent.AXIS_Z to 0f, MotionEvent.AXIS_RZ to -0.6f)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_R2)
|
||||
assertEquals(0.5f, pad().leftTrigger)
|
||||
assertEquals(0f, pad().rightTrigger)
|
||||
assertEquals(0.2f, pad().rightTrigger, 0.0001f)
|
||||
}
|
||||
|
||||
@Test fun digitalTriggerKeysStillPressAndRelease() {
|
||||
@@ -148,11 +148,46 @@ class PadPartialAxesTest {
|
||||
}
|
||||
|
||||
@Test fun keyTriggerDecision() {
|
||||
assertEquals(1f, PadBridge.keyTrigger(0f, true, 0, false))
|
||||
assertEquals(0f, PadBridge.keyTrigger(1f, false, 0, false))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, true, 1, false))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, true, 0, true))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, false, 0, true))
|
||||
val held = BooleanArray(2)
|
||||
assertEquals(1f, PadBridge.keyTrigger(0f, true, 0, false, held, 0))
|
||||
assertEquals(0f, PadBridge.keyTrigger(1f, false, 0, false, held, 0))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, true, 1, false, held, 0))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, true, 0, true, held, 0))
|
||||
assertEquals(0.25f, PadBridge.keyTrigger(0.25f, false, 0, true, held, 0))
|
||||
assertEquals(1f, PadBridge.keyTrigger(0f, true, 0, true, held, 1))
|
||||
assertEquals(1f, PadBridge.axisTrigger(0f, held, 1))
|
||||
assertEquals(0f, PadBridge.keyTrigger(1f, false, 0, true, held, 1))
|
||||
assertEquals(0f, PadBridge.axisTrigger(0f, held, 1))
|
||||
}
|
||||
|
||||
@Test fun silentTriggerAxesLetTheirKeysPress() {
|
||||
val device = device(MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER)
|
||||
motion(device)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_R2)
|
||||
assertEquals(1f, pad().leftTrigger)
|
||||
assertEquals(1f, pad().rightTrigger)
|
||||
motion(device, MotionEvent.AXIS_X to 0.8f)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2, repeat = 1)
|
||||
assertEquals(1f, pad().leftTrigger)
|
||||
assertEquals(1f, pad().rightTrigger)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.ACTION_UP)
|
||||
assertEquals(0f, pad().leftTrigger)
|
||||
assertEquals(1f, pad().rightTrigger)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_R2, KeyEvent.ACTION_UP)
|
||||
assertEquals(0f, pad().rightTrigger)
|
||||
}
|
||||
|
||||
@Test fun aLiveAxisTakesOverFromItsKey() {
|
||||
val device = device(MotionEvent.AXIS_LTRIGGER)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2)
|
||||
assertEquals(1f, pad().leftTrigger)
|
||||
motion(device, MotionEvent.AXIS_LTRIGGER to 0.4f)
|
||||
assertEquals(0.4f, pad().leftTrigger)
|
||||
motion(device, MotionEvent.AXIS_LTRIGGER to 0f)
|
||||
assertEquals(0f, pad().leftTrigger)
|
||||
key(device, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.ACTION_UP)
|
||||
assertEquals(0f, pad().leftTrigger)
|
||||
}
|
||||
|
||||
private fun pad(): PadState = requireNotNull(JoyConPadTest.RecordingWriter.pads[0])
|
||||
|
||||
@@ -259,6 +259,29 @@ class BattleNetTest {
|
||||
BattleNet.hostFolder(context, "Z:/root/Games/Stores/Battle.net/Hearthstone/"))
|
||||
}
|
||||
|
||||
@Test fun aMappedWineDriveResolvesThroughItsLink() {
|
||||
val devices = File(BattleNet.prefix(context), "pfx/dosdevices").apply { mkdirs() }
|
||||
java.nio.file.Files.createSymbolicLink(File(devices, "d:").toPath(), java.nio.file.Paths.get("/root/Games/Card"))
|
||||
java.nio.file.Files.createSymbolicLink(File(devices, "e:").toPath(), java.nio.file.Paths.get("../drive_c"))
|
||||
assertEquals(File(LinuxRuntime.rootDir(context), "root/Games/Card/Battle.net/Hearthstone"),
|
||||
BattleNet.hostFolder(context, "D:\\Battle.net\\Hearthstone"))
|
||||
assertEquals(File(BattleNet.prefix(context), "pfx/drive_c/Games/X"), BattleNet.hostFolder(context, "E:\\Games\\X"))
|
||||
assertNull(BattleNet.hostFolder(context, "F:\\Games\\X"))
|
||||
assertNull(BattleNet.hostFolder(context, "D:\\..\\etc"))
|
||||
}
|
||||
|
||||
@Test fun aDriveLinkTheNativeUserlandMadeRelativeStillResolvesInTheGuest() {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val devices = File(BattleNet.prefix(context), "pfx/dosdevices").apply { mkdirs() }
|
||||
java.nio.file.Files.createSymbolicLink(File(devices, "d:").toPath(), java.nio.file.Paths.get("/root/Games/Card"))
|
||||
java.nio.file.Files.createSymbolicLink(File(devices, "g:").toPath(), java.nio.file.Paths.get("../../../../../../../../../../.."))
|
||||
NativeGuest.relinkAbsoluteSymlinks(context, root)
|
||||
assertFalse(java.nio.file.Files.readSymbolicLink(File(devices, "d:").toPath()).isAbsolute)
|
||||
assertEquals(File(root, "root/Games/Card/Battle.net/Hearthstone"),
|
||||
BattleNet.hostFolder(context, "D:\\Battle.net\\Hearthstone"))
|
||||
assertNull(BattleNet.hostFolder(context, "G:\\etc\\passwd"))
|
||||
}
|
||||
|
||||
|
||||
@Test fun wowForeverIsListedBesideRetailInTheSharedWorldOfWarcraftFolder() {
|
||||
val pfx = File(BattleNet.prefix(context), "pfx").apply { mkdirs() }
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import com.droiddeck.launcher.ui.BuildWatermark
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.ConscryptMode
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@ConscryptMode(ConscryptMode.Mode.OFF)
|
||||
@GraphicsMode(GraphicsMode.Mode.LEGACY)
|
||||
@Config(sdk = [34], manifest = Config.NONE, application = Application::class)
|
||||
class BuildWatermarkSettingTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
private val originalLineProvider = BuildWatermark.lineProvider
|
||||
|
||||
@Before fun clear() {
|
||||
BuildWatermark.lineProvider = {
|
||||
BuildWatermark.lines(pr = 1234, ci = true, commit = "abcdef0123", title = "Test build", branch = "")
|
||||
}
|
||||
listOf("build-watermark", "build-watermark.new", "build-watermark.bak", "build-watermark.lock").forEach {
|
||||
File(context.filesDir, it).delete()
|
||||
}
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@After fun cleanUp() {
|
||||
try {
|
||||
clear()
|
||||
} finally {
|
||||
BuildWatermark.lineProvider = originalLineProvider
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun defaultsOnAndPreservesLegacyChoice() {
|
||||
assertTrue(SessionPrefs.buildWatermarkEnabled(context))
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("buildWatermark", false).commit()
|
||||
assertFalse(SessionPrefs.buildWatermarkEnabled(context))
|
||||
}
|
||||
|
||||
@Test fun sharedFileOverridesCachedPreferencesAndNotifiesOtherProcesses() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("buildWatermark", false).commit()
|
||||
SessionPrefs.setBuildWatermarkEnabled(context, true)
|
||||
assertTrue(SessionPrefs.buildWatermarkEnabled(context))
|
||||
val change = shadowOf(context).broadcastIntents.last { it.action == BuildWatermarkSetting.CHANGED }
|
||||
assertEquals(context.packageName, change.`package`)
|
||||
File(context.filesDir, "build-watermark").writeText("off")
|
||||
assertFalse(SessionPrefs.buildWatermarkEnabled(context))
|
||||
}
|
||||
|
||||
@Test fun lockOpenFailureFallsBackToPreferences() {
|
||||
val lock = File(context.filesDir, "build-watermark.lock")
|
||||
assertTrue(lock.mkdir())
|
||||
assertTrue(SessionPrefs.buildWatermarkEnabled(context))
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("buildWatermark", false).commit()
|
||||
assertFalse(SessionPrefs.buildWatermarkEnabled(context))
|
||||
assertThrows(java.io.IOException::class.java) { SessionPrefs.setBuildWatermarkEnabled(context, true) }
|
||||
}
|
||||
|
||||
@Test fun settingReadFailureFallsBackToPreferences() {
|
||||
assertTrue(File(context.filesDir, "build-watermark").mkdir())
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("buildWatermark", false).commit()
|
||||
assertFalse(SessionPrefs.buildWatermarkEnabled(context))
|
||||
}
|
||||
|
||||
@Test fun remoteChangeRefreshesExistingAndRecreatedActivities() {
|
||||
BuildWatermark.install(context)
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup()
|
||||
try {
|
||||
assertNotNull(activity.get().window.decorView.findViewWithTag<View>("build-watermark"))
|
||||
File(context.filesDir, "build-watermark").writeText("off")
|
||||
context.sendBroadcast(Intent(BuildWatermarkSetting.CHANGED).setPackage(context.packageName))
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertNull(activity.get().window.decorView.findViewWithTag<View>("build-watermark"))
|
||||
activity.recreate()
|
||||
assertNull(activity.get().window.decorView.findViewWithTag<View>("build-watermark"))
|
||||
SessionPrefs.setBuildWatermarkEnabled(context, true)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertNotNull(activity.get().window.decorView.findViewWithTag<View>("build-watermark"))
|
||||
} finally {
|
||||
activity.pause().stop().destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,9 +273,12 @@ class SessionSuspendControllerTest {
|
||||
val fixture = fixture(17)
|
||||
fixture.clock.writeBytes(shift(5_000_000L))
|
||||
fixture.controller.freeze { assertTrue(it) }
|
||||
fixture.clock.delete()
|
||||
fixture.clock.writeBytes(shift(5_000_000L))
|
||||
fixture.now += 90_000L
|
||||
fixture.controller.resume { assertTrue(it) }
|
||||
val expected = 5_000_000L + 90_000L * 1_000_000L
|
||||
assertEquals(5_000_000L, fixture.clockAtMapping)
|
||||
assertEquals(expected, fixture.clockAtContinue)
|
||||
assertEquals(expected, shift(fixture.clock))
|
||||
fixture.controller.resume { assertTrue(it) }
|
||||
@@ -478,6 +481,7 @@ class SessionSuspendControllerTest {
|
||||
val control = File(tmp, ".bl-zram")
|
||||
val clock = File(tmp, ".bl-clock")
|
||||
var clockAtContinue: Long? = null
|
||||
var clockAtMapping: Long? = null
|
||||
val pagedOut = mutableListOf<Triple<Int, Long, Long>>()
|
||||
val pagedIn = mutableListOf<Triple<Int, Long, Long>>()
|
||||
var availableKb = 8_000_000L
|
||||
@@ -496,6 +500,7 @@ class SessionSuspendControllerTest {
|
||||
proc, ::signal, ::threadSignal, ::sleep, { now }, executor,
|
||||
zramControl = control,
|
||||
clockControl = clock,
|
||||
mapClockControl = { clockAtMapping = ByteBuffer.wrap(it.readBytes()).order(ByteOrder.LITTLE_ENDIAN).long },
|
||||
nowNanos = { now * 1_000_000L },
|
||||
pagedOut = { processes, unacknowledged, resident, swap, _, _ ->
|
||||
pagedOut += Triple(processes, resident, swap)
|
||||
|
||||
@@ -65,6 +65,26 @@ class SteamLibrariesTest {
|
||||
assertTrue(SteamLibraries.add(app, nested.path) is SteamLibraries.AddResult.Refused)
|
||||
}
|
||||
|
||||
@Test fun aGamesFolderIsOfferedWholeUnlessItIsAlreadyALibrary() {
|
||||
val games = "/storage/card/GameNative"
|
||||
assertTrue(SteamLibraries.offered(emptyList(), listOf(games), games))
|
||||
assertFalse(SteamLibraries.offered(emptyList(), listOf(games), "$games/Steam"))
|
||||
assertFalse(SteamLibraries.offered(emptyList(), listOf(games), "/storage/card"))
|
||||
assertFalse(SteamLibraries.offered(listOf(games), listOf(games), games))
|
||||
assertTrue(SteamLibraries.offered(listOf("/storage/card/Other"), listOf(games), "/storage/card/New"))
|
||||
}
|
||||
|
||||
@Test fun aGamesFolderRemainsDiscoverableBesideChosenGameStorage() {
|
||||
val card = tmp.newFolder("card")
|
||||
val games = File(card, "GameNative").path
|
||||
val selected = File(card, "Selected").apply { mkdirs() }
|
||||
SessionPrefs.setGameStorage(app, selected.path, "Selected")
|
||||
val effective = GameStorage.effective(app)!!
|
||||
assertTrue(SteamLibraries.offered(listOf(effective.path), listOf(games), games))
|
||||
SessionPrefs.setGameStorage(app, card.path, "Card")
|
||||
assertFalse(SteamLibraries.offered(listOf(GameStorage.effective(app)!!.path), listOf(games), games))
|
||||
}
|
||||
|
||||
private fun library(dir: File): File = dir.apply { File(this, "steamapps").mkdirs(); File(this, "steamapps/appmanifest_220.acf").writeText("x") }
|
||||
|
||||
@Test fun pickingTheFolderAboveALibraryAddsTheLibraryInside() {
|
||||
@@ -124,6 +144,14 @@ class SteamLibrariesTest {
|
||||
assertEquals("${lib.guest}/steamapps/common/Game", AddedGames.guestPath(app, scanned.folder))
|
||||
}
|
||||
|
||||
@Test fun rawAndStorageMountViewsOverlapButDistinctFoldersDoNot() {
|
||||
val raw = SteamLibraries.canonical(File("/mnt/media_rw/ABCD-1234/Games"))
|
||||
val storage = SteamLibraries.canonical(File("/storage/ABCD-1234/Games"))
|
||||
assertEquals(storage, raw)
|
||||
assertTrue(SteamLibraries.overlaps(raw, SteamLibraries.canonical(File("/storage/ABCD-1234/Games/Steam"))))
|
||||
assertFalse(SteamLibraries.overlaps(raw, SteamLibraries.canonical(File("/storage/ABCD-1234/Other"))))
|
||||
}
|
||||
|
||||
@Test fun unreadableOrInvalidEntriesAreDropped() {
|
||||
assertTrue(SteamLibraries.parse("{oops").isEmpty())
|
||||
assertEquals(listOf("2"), SteamLibraries.parse("""[{"id":"../x","path":"/a"},{"id":"2","path":"/b","label":""}]""").map { it.id })
|
||||
|
||||
@@ -50,6 +50,8 @@ class SteamStorageTest {
|
||||
assertEquals(SteamStorage.Kind.X86_PROTON, SteamStorage.kind(manifest(1493710, "Proton - Experimental")))
|
||||
assertEquals(SteamStorage.Kind.X86_PROTON, SteamStorage.kind(manifest(2805730, "Proton 9.0")))
|
||||
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(228980, "Steamworks Shared")))
|
||||
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(1826330, "Proton EasyAntiCheat Runtime")))
|
||||
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(1161040, "Proton BattlEye Runtime")))
|
||||
assertTrue(SteamStorage.isTool(manifest(2805730, "Proton 9.0")))
|
||||
assertFalse(SteamStorage.isTool(manifest(204360, "CastleCrashers")))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,43 @@ class EaTest {
|
||||
assertNull(EaCrypto.challengeSeed("9", answer))
|
||||
}
|
||||
|
||||
@Test fun onlyTokenRejectionsAreRetried() {
|
||||
val rejected = EaReply(409, "<error code=\"VALIDATION_FAILED\"><failure cause=\"AUTHENTICATION_FAILED\" field=\"ea_eadmtoken\"/></error>".toByteArray(), emptyMap())
|
||||
val notEntitled = EaReply(409, "<error code=\"VALIDATION_FAILED\"><failure cause=\"NOT_ENTITLED\" field=\"userId\" value=\"1\"/></error>".toByteArray(), emptyMap())
|
||||
var authCalls = 0
|
||||
val success = EaReply(200, ByteArray(0), emptyMap())
|
||||
val result = EaOoa.retryTokenRejected {
|
||||
authCalls++
|
||||
if (authCalls == 1) rejected else success
|
||||
}
|
||||
assertEquals(2, authCalls)
|
||||
assertEquals(200, result.status)
|
||||
|
||||
var entitlementCalls = 0
|
||||
val refused = EaOoa.retryTokenRejected {
|
||||
entitlementCalls++
|
||||
notEntitled
|
||||
}
|
||||
assertEquals(1, entitlementCalls)
|
||||
assertEquals(409, refused.status)
|
||||
|
||||
val otherAuthFailure = EaReply(409, "<error code=\"VALIDATION_FAILED\"><failure cause=\"AUTHENTICATION_FAILED\" field=\"machineHash\"/></error>".toByteArray(), emptyMap())
|
||||
var otherAuthCalls = 0
|
||||
val otherRefused = EaOoa.retryTokenRejected {
|
||||
otherAuthCalls++
|
||||
otherAuthFailure
|
||||
}
|
||||
assertEquals(1, otherAuthCalls)
|
||||
assertEquals(409, otherRefused.status)
|
||||
|
||||
var failedCalls = 0
|
||||
EaOoa.retryTokenRejected {
|
||||
failedCalls++
|
||||
rejected
|
||||
}
|
||||
assertEquals(4, failedCalls)
|
||||
}
|
||||
|
||||
@Test fun licenceRoundTripsThroughTheFileFormat() {
|
||||
val xml = "<?xml version=\"1.0\"?><License xmlns=\"http://ea.com/license\"><CipherKey>k</CipherKey><MachineHash>h</MachineHash>" +
|
||||
"<ContentId>1234</ContentId><UserId>9</UserId><GameToken>tok</GameToken><GrantTime>g</GrantTime><StartTime>s</StartTime><Nonce>n</Nonce></License>"
|
||||
|
||||
@@ -302,10 +302,17 @@ class GogMaintenanceTest {
|
||||
File(folder, "Game.bin").writeText("original")
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"game.bin"}]"""), folder) }
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":".droiddeck-store.json"}]"""), folder) }
|
||||
val data = File(folder, "Data").apply { mkdirs() }
|
||||
val installed = JSONArray()
|
||||
for (i in 0 until 2000) { File(data, "Part$i.bin").writeText(""); installed.put(JSONObject().put("path", "Data/Part$i.bin")) }
|
||||
GogFiles.validate(installed, folder)
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"Data/Part0.bin"},{"path":"Data/part1.bin"}]"""), folder) }
|
||||
val outside = temporary.newFolder()
|
||||
try { Files.createSymbolicLink(File(folder, "link").toPath(), outside.toPath()) }
|
||||
catch (_: java.nio.file.FileSystemException) { return }
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, "link/game.exe") }
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"Data/Part0.bin"},{"path":"link/game.exe"}]"""), folder) }
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"a/./file"}]"""), folder) }
|
||||
}
|
||||
|
||||
@Test fun resumedInstallSpaceCountsOnlyFilesStillMissing() {
|
||||
|
||||
@@ -9,8 +9,10 @@ Every five seconds the monitor reads `MemAvailable`, swap headroom and memory PS
|
||||
headroom means less than one fifth of total RAM (bounded to 512–1536 MiB), or at least 1%
|
||||
`some avg10` memory stall with less than one third of RAM available. Pressure requests target
|
||||
only the launcher processes already recognized by the monitor, never the game. They require
|
||||
at least 128 MiB launcher RSS and enough free swap to cover its entire RSS while leaving
|
||||
one fifth of swap (at least 256 MiB) free. The regular automatic client request uses the
|
||||
at least 128 MiB launcher RSS and enough free swap to cover its anonymous RSS while leaving
|
||||
one fifth of swap (at least 256 MiB) free. File-backed resident pages do not consume this
|
||||
budget: the page-out helper targets private anonymous mappings. If `RssAnon` is absent from a
|
||||
process's status, its full RSS is used instead. The regular automatic client request uses the
|
||||
same swap guard. An explicit session RAM cap retains its existing behavior.
|
||||
|
||||
Pressure requests are at least 30 seconds apart. Reclaiming less than 16 MiB doubles the
|
||||
@@ -41,27 +43,32 @@ reduced aggregate launcher RSS only from 263 to 255 MiB and backed off. Thus lau
|
||||
does not resolve this load failure, and this run did not exhaust swap. The unevictable/shmem
|
||||
growth needs allocation attribution before treating it as a driver or GPU-memory diagnosis.
|
||||
|
||||
## Low-RAM devices: compressing idle game memory
|
||||
## Pressure reclaim: compressing idle game memory
|
||||
|
||||
On devices with at most 13 GiB `MemTotal` (every 12 GB phone and handheld and below), the
|
||||
monitor also compresses the game session itself when memory runs out. The trigger is absolute
|
||||
headroom, not a percentage: Android's `lmkd` killed a session on a 7.5 GiB device with
|
||||
The monitor also compresses the game session itself when observed pressure approaches the
|
||||
system kill boundary. There is no RAM-size gate. The trigger is absolute real headroom, not a
|
||||
percentage: Android's `lmkd` killed a session on a 7.5 GiB device with
|
||||
490 MiB still available, so a 95% used threshold (about 400 MiB left on 8 GB) is already past the
|
||||
kill. A world load added 1 GiB of GPU memory in about ten seconds, and compressing 1.4 GiB took
|
||||
2.6 seconds, so the request has to leave room for both.
|
||||
|
||||
The session request runs when available memory falls below a quarter of RAM (bounded to
|
||||
768–2560 MiB, 1.85 GiB on an 8 GB device), or below one and a half times that with at least 1%
|
||||
`some avg10` memory stall. One request pages out every session process that loaded
|
||||
`libblsession.so`: the game, Wine and its services, plus the launcher processes unless their
|
||||
compression is turned off, under a single wait. It runs before the launcher-only check, which
|
||||
can wait up to 12 seconds for acknowledgements. While a game runs and available memory is under
|
||||
twice that level, memory is read every second instead of every five. Requests are at least 30 seconds apart and need the same swap reserve as launcher
|
||||
requests. When the session has read back more than half of what the previous request compressed,
|
||||
that memory is in use: the request is skipped and the wait doubles, up to five minutes. A
|
||||
request whose memory stayed compressed resets it to 30 seconds. Below half the headroom the
|
||||
wait is ignored and only the 30-second spacing applies. The state resets when the game exits. Devices above 13 GiB keep the
|
||||
launcher-only behaviour.
|
||||
The session request runs when real headroom falls below a quarter of RAM (bounded to
|
||||
768–2560 MiB), or below one and a half times that with at least 1% `some` or `full avg10`
|
||||
memory stall. Real headroom subtracts positive growth in KGSL allocation, `Unevictable`, and
|
||||
`Shmem` from `MemAvailable`; a rapid fall also qualifies near twice the headroom. The monitor
|
||||
may first request page-out of idle Steam and hidden launchers, subject to the Performance
|
||||
setting and client reclaim limits. When both qualify, both requests are sent before waiting
|
||||
for either to complete, so a slow launcher acknowledgement does not delay the game request. Until the client
|
||||
request finishes, its anonymous RSS remains reserved from the session's swap budget. Completion
|
||||
and backoff are measured against the processes that received each request, even if a later scan
|
||||
finds different processes. The session request targets processes that loaded
|
||||
`libblsession.so`: the game, Wine and its services, and excludes Steam and hidden launchers.
|
||||
Requests are at least 30 seconds apart and need the same
|
||||
swap reserve as launcher requests. When the session has read back more than half of what the
|
||||
previous request compressed, that memory is in use: the request is skipped and the wait doubles,
|
||||
up to five minutes. A request whose memory stayed compressed resets it to 30 seconds. Below half
|
||||
the headroom the wait is ignored and only the 30-second spacing applies. The state resets when
|
||||
the game exits.
|
||||
|
||||
Measured on a Retroid Pocket Duo (7.5 GiB, kernel 6.6, 6 GiB lzo-rle zram plus a 4 GiB swap file)
|
||||
entering the world in World of Warcraft: Forever, one run each:
|
||||
@@ -91,7 +98,8 @@ A second run on that build showed the trigger arriving too late: character selec
|
||||
there. During the load, available memory crossed the headroom 9 seconds after Enter World, but the
|
||||
launcher check ran first and waited 12 seconds for acknowledgements; the game request started at
|
||||
21 seconds, moved about 200 MiB, and `lmkd` ended the session at 30 seconds. The quarter-of-RAM
|
||||
level and the game-first single request address both.
|
||||
level starts reclaim earlier. Sending both requests before waiting removes the launcher
|
||||
acknowledgement delay; it does not guarantee that reclaim can keep up with every loading peak.
|
||||
|
||||
Without root an app cannot write its memory cgroup's `memory.reclaim` (owned by `system`),
|
||||
`process_madvise` on another process returns `EPERM`, `PAGEMAP_SCAN` is absent before Linux 6.7,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Reported video memory budget
|
||||
|
||||
The Performance VRAM setting enables `VK_LAYER_DROIDDECK_vram_cap` for games and reports smaller device-local `VK_EXT_memory_budget` budgets. DXVK exposes these through DXGI's `QueryVideoMemoryInfo`, including to D3D12 games using DXVK's DXGI alongside vkd3d-proton. Games that consult this budget can choose smaller working sets. The driver still supplies real heap sizes, usage figures, memory types, and actual allocation results. DXVK also receives its existing `dxgi.maxDeviceMemory` setting, which separately reduces the adapter's advertised dedicated video memory. A lower driver budget remains lower; the layer never increases it.
|
||||
The Performance VRAM setting enables `VK_LAYER_DROIDDECK_vram_cap` for games and reports smaller device-local `VK_EXT_memory_budget` budgets. DXVK exposes these through DXGI's `QueryVideoMemoryInfo`, including to D3D12 games using DXVK's DXGI alongside vkd3d-proton. Games that consult this budget can choose smaller working sets. The driver still supplies real heap sizes, usage figures, memory types, and actual allocation results. DXVK also receives its existing `dxgi.maxDeviceMemory` setting, which separately reduces the adapter's advertised dedicated video memory, and, for caps below DXVK's 4096 MiB D3D9 default, `d3d9.maxAvailableMemory`, which caps the texture memory DXVK's D3D9 frontend reports, since D3D9 games do not read the DXGI setting. Higher caps leave the D3D9 setting alone so DXVK's lower per-game limits still apply. OpenGL games are not covered. A lower driver budget remains lower; the layer never increases it.
|
||||
|
||||
This does not make vkd3d-proton automatically enforce the selected budget. In the [upstream allocator inspected during this investigation](https://github.com/HansKristian-Work/vkd3d-proton/blob/865b12ba0cc20796b3709397ce5d6d3b8a205b04/libs/vkd3d/memory.c), `heapBudget` is used for diagnostic reporting, while the [DXVK adapter](https://github.com/doitsujin/dxvk/blob/cf0a0d90f8965147741ad17dcd0b263817572ab3/src/dxgi/dxgi_adapter.cpp) returns the Vulkan budget to applications. Seeing a smaller budget in a VKD3D log verifies reporting, not reduced allocation or eviction.
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ or by hand, and loaded over the installed APK at the next start. Nothing is rein
|
||||
re-signed, so a tester on a release build keeps their games, sign-ins and settings while trying a
|
||||
change.
|
||||
|
||||
While a Lab build is active, add-on packs reuse the newest complete copy already installed on the
|
||||
device, even when the checkout pins another version. This keeps helpers such as proot available
|
||||
after restarting. If no complete copy exists, the pack uses the checkout's pin.
|
||||
|
||||
## How it works
|
||||
|
||||
- `tools/agent/dd-lab-build` builds the checkout with Gradle on the device (`processReleaseResources`
|
||||
@@ -25,6 +29,11 @@ change.
|
||||
Updates lists them under Lab builds: the one in use can be turned off, one built for this
|
||||
install can be used again, and any can be deleted.
|
||||
|
||||
The build targets the package owning `DD_AGENT_INBOX` when run from the Agent tab.
|
||||
For a manual build of another package, set `DROIDDECK_PACKAGE`, for example
|
||||
`DROIDDECK_PACKAGE=com.tencent.ig dd-lab-build`. This also selects the control provider,
|
||||
Lab storage, and package to restart.
|
||||
|
||||
## What a Lab build cannot change
|
||||
|
||||
The manifest, native code (`cpp`, `jniLibs`, Rust), Gradle files, `App`, `AppLanguage`, and `LabOverlay` itself:
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
# compile what changed. Setup runs it once; it takes the longest.
|
||||
# --explain logs why each task ran and Kotlin's build report, to find out what makes a build slow.
|
||||
set -euo pipefail
|
||||
pkg=com.droiddeck.launcher
|
||||
pkg=${DROIDDECK_PACKAGE:-}
|
||||
if [ -z "$pkg" ] && [[ ${DD_AGENT_INBOX:-} =~ ^/data/(user/[0-9]+|data)/([^/]+)/files/agent$ ]]; then
|
||||
pkg=${BASH_REMATCH[2]}
|
||||
fi
|
||||
pkg=${pkg:-com.droiddeck.launcher}
|
||||
serial=${ADB_SERIAL:-127.0.0.1:5555}
|
||||
mode=build
|
||||
explain=()
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--package=*) pkg=${arg#--package=} ;;
|
||||
--no-restart) mode=no-restart ;;
|
||||
--prepare) mode=prepare ;;
|
||||
--explain) explain=(--info -Pkotlin.build.report.output=file) ;;
|
||||
@@ -31,16 +36,18 @@ progress() { # stage [permille task done total eta]
|
||||
fail() { progress failed 0 "$*"; echo "dd-lab-build: $*" >&2; exit 1; }
|
||||
trap 'progress failed 0 stopped; exit 143' INT TERM
|
||||
trap 'progress failed 0 "a command failed on line $LINENO"' ERR
|
||||
[[ $pkg =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || fail "invalid Android package: $pkg"
|
||||
export DROIDDECK_PACKAGE=$pkg
|
||||
device() { adb -s "$serial" shell "$@" | tr -d '\r'; }
|
||||
|
||||
# The app's agent loses every process it started when its turn ends, a build included, so it asks
|
||||
# the app to run the build in a process of its own and follows that build here.
|
||||
agent=/data/user/0/$pkg/files/agent
|
||||
agent=${DD_AGENT_INBOX:-/data/user/0/$pkg/files/agent}
|
||||
if [ -n "${DD_AGENT:-}" ] && [ -z "${DD_LAB_DETACHED:-}" ] && [ -d "$agent" ]; then
|
||||
trap - ERR
|
||||
log=$cache/dd-lab-build.log
|
||||
rm -f "$progress_file"
|
||||
python3 -c 'import json,os,sys; print(json.dumps({"cwd": os.getcwd(), "args": sys.argv[1:]}))' "$@" > "$agent/lab-request.part"
|
||||
python3 -c 'import json,os,sys; print(json.dumps({"cwd": os.getcwd(), "args": [*sys.argv[1:], "--package=" + os.environ["DROIDDECK_PACKAGE"]]}))' "$@" > "$agent/lab-request.part"
|
||||
mv "$agent/lab-request.part" "$agent/lab-request"
|
||||
echo "DroidDeck runs this build itself, so it keeps going if this turn ends. Its log follows."
|
||||
for _ in $(seq 1 30); do [ -f "$progress_file" ] && break; sleep 1; done
|
||||
@@ -102,7 +109,7 @@ export DROIDDECK_SOURCE_COMMIT=$base DROIDDECK_BUILD_LABEL="lab on $installed_la
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||
if [ ! -d "$ANDROID_HOME/platforms/android-34" ]; then
|
||||
progress sdk
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
|
||||
(set +o pipefail; yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null)
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;35.0.0" >/dev/null
|
||||
fi
|
||||
|
||||
@@ -125,7 +132,19 @@ gradle=(./gradlew :app:processReleaseResources :app:mergeDexRelease --max-worker
|
||||
-Pandroid.aapt2FromMavenOverride=$HOME/aapt2/aapt2 -Pkotlin.compiler.execution.strategy=daemon
|
||||
-Pkotlin.daemon.jvmargs=-Xmx${kotlin_heap}m -Dorg.gradle.jvmargs=-Xmx${gradle_heap}m --console=plain)
|
||||
progress planning
|
||||
mapfile -t plan < <("${gradle[@]}" --dry-run -q 2>/dev/null | awk '/^:/ {print $1}' || true)
|
||||
plan_file=$cache/dd-lab-build-plan
|
||||
plan_key=$(
|
||||
{
|
||||
printf '%s\n' "$repo" "${gradle[@]}"
|
||||
git ls-files -z -- '*gradle*' | xargs -0 -r sha256sum
|
||||
} | sha256sum | cut -d ' ' -f1
|
||||
)
|
||||
if [ "$(head -n 1 "$plan_file" 2>/dev/null)" != "$plan_key" ]; then
|
||||
plan_output=$("${gradle[@]}" --dry-run) || fail "Gradle planning failed: $plan_output"
|
||||
{ printf '%s\n' "$plan_key"; awk '/^:/ {print $1}' <<<"$plan_output"; } > "$plan_file.part"
|
||||
mv "$plan_file.part" "$plan_file"
|
||||
fi
|
||||
mapfile -t plan < <(tail -n +2 "$plan_file")
|
||||
total=${#plan[@]}
|
||||
|
||||
# Each task's estimate is how long it took last time; a full compile of the app takes about 15 minutes.
|
||||
@@ -221,6 +240,6 @@ device cmd package compile -m verify -f $pkg >/dev/null
|
||||
progress restarting 1000 "$name"
|
||||
echo "Restarting DroidDeck on the Lab build; it shows a notification when it is back"
|
||||
# The app's own agent ends with the app, so it is told to carry on once the app is back.
|
||||
if [ -n "${DD_AGENT:-}" ]; then mkdir -p "/data/user/0/$pkg/files/agent" && : > "/data/user/0/$pkg/files/agent/resume"; fi
|
||||
if [ -n "${DD_AGENT:-}" ]; then mkdir -p "$agent" && : > "$agent/resume"; fi
|
||||
device "nohup sh -c 'sleep 3; monkey -p $pkg -c android.intent.category.LAUNCHER 1' >/dev/null 2>&1 &"
|
||||
for pid in $(device pidof $pkg); do kill "$pid" 2>/dev/null || true; done
|
||||
+1
-1
@@ -61,7 +61,7 @@ if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
mv "$sdk/cmdline-tools/cmdline-tools" "$sdk/cmdline-tools/latest"
|
||||
fi
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
||||
yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
|
||||
(set +o pipefail; yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null)
|
||||
"$sdk/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;35.0.0" >/dev/null
|
||||
|
||||
adb connect 127.0.0.1:5555 || true
|
||||
|
||||
@@ -41,6 +41,9 @@ FEX_WAIT = 120
|
||||
START_GRACE = 90
|
||||
POLL = 2
|
||||
STOP_WAIT = 5
|
||||
DRIVES = "defghijklmnopqruvwxy"
|
||||
PRODUCT_DB = ("ProgramData", "Battle.net", "Agent", "product.db")
|
||||
INSTALL_FOLDER = (1, 3, 1)
|
||||
CLOSE_GRACE = 15
|
||||
HANDOFF_WAIT = 120
|
||||
GAME_SETTLE = 20
|
||||
@@ -123,7 +126,7 @@ function install() {
|
||||
save(".form", form);
|
||||
if (form.indexOf('"' + lang + '"') < 0) lang = "enUS";
|
||||
var locale = lang.toLowerCase();
|
||||
call("POST", "/install/" + uid, '{"account_country":"' + country + '","finalized":true,"game_dir":"' + dir + '","geo_ip_country":"' +
|
||||
call("POST", "/install/" + uid, '{"account_country":"' + country + '","finalized":true,"game_dir":"' + dir.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '","geo_ip_country":"' +
|
||||
region + '","language":["' + locale + '"],"selected_asset_locale":"' + locale + '","selected_locale":"' + locale +
|
||||
'","shortcut":"none","tome_torrent":""}');
|
||||
watch("/install/" + uid);
|
||||
@@ -226,8 +229,72 @@ def installed(root):
|
||||
return os.path.isfile(launcher(root))
|
||||
|
||||
|
||||
def install_path(library):
|
||||
return "Z:" + library.rstrip("/")
|
||||
def dosdevices(root):
|
||||
return os.path.join(prefix(root), "pfx", "dosdevices")
|
||||
|
||||
|
||||
def volume_root(path):
|
||||
device = os.stat(path).st_dev
|
||||
while path != "/":
|
||||
parent = os.path.dirname(path)
|
||||
if os.stat(parent).st_dev != device:
|
||||
break
|
||||
path = parent
|
||||
return path
|
||||
|
||||
|
||||
def inside(path, folder):
|
||||
return path == folder or path.startswith(folder.rstrip("/") + "/")
|
||||
|
||||
|
||||
def mapped_drive(root, path, volume):
|
||||
folder = dosdevices(root)
|
||||
try:
|
||||
names = sorted(os.listdir(folder))
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
link = os.path.join(folder, name)
|
||||
if len(name) == 2 and name[0].lower() in DRIVES and name[1] == ":" and os.path.islink(link):
|
||||
target = os.path.realpath(link)
|
||||
if inside(target, volume) and inside(path, target):
|
||||
return name[0].lower(), target
|
||||
return None
|
||||
|
||||
|
||||
def map_drive(root, volume):
|
||||
folder = dosdevices(root)
|
||||
for letter in DRIVES:
|
||||
link = os.path.join(folder, letter + ":")
|
||||
if not os.path.lexists(link):
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
os.symlink(volume, link)
|
||||
except OSError as error:
|
||||
say("%s not mapped to a Wine drive: %s" % (volume, error))
|
||||
return None
|
||||
say("Wine drive %s: is %s" % (letter.upper(), volume))
|
||||
return letter, volume
|
||||
say("%s not mapped to a Wine drive: every letter is taken" % volume)
|
||||
return None
|
||||
|
||||
|
||||
def install_path(root, library):
|
||||
library = library.rstrip("/") or "/"
|
||||
try:
|
||||
path = os.path.realpath(library)
|
||||
volume = volume_root(path)
|
||||
except OSError:
|
||||
return "Z:" + library
|
||||
if volume == "/":
|
||||
return "Z:" + library
|
||||
drive = mapped_drive(root, path, volume)
|
||||
if drive is None and not wine_processes(os.path.join(prefix(root), "pfx")):
|
||||
drive = map_drive(root, volume)
|
||||
if drive is None:
|
||||
return "Z:" + library
|
||||
letter, target = drive
|
||||
return letter.upper() + ":\\" + path[len(target):].strip("/").replace("/", "\\")
|
||||
|
||||
|
||||
def write_config(root, library=None):
|
||||
@@ -254,8 +321,8 @@ def write_config(root, library=None):
|
||||
client["HardwareAcceleration"] = "false"
|
||||
changes.append("acceleration")
|
||||
if library and isinstance(client.setdefault("Install", {}), dict) \
|
||||
and str(client["Install"].get("DefaultInstallPath", "")).rstrip("/\\") != install_path(library):
|
||||
client["Install"]["DefaultInstallPath"] = install_path(library)
|
||||
and str(client["Install"].get("DefaultInstallPath", "")).rstrip("/\\") != install_path(root, library).rstrip("\\"):
|
||||
client["Install"]["DefaultInstallPath"] = install_path(root, library)
|
||||
changes.append("library")
|
||||
games = config.setdefault("Games", {})
|
||||
for uid in CLIENT_32_BIT if isinstance(games, dict) else ():
|
||||
@@ -417,13 +484,106 @@ def wallpaper(root):
|
||||
|
||||
|
||||
def windows_path(root, path):
|
||||
found = re.match(r"(?i)^([cz]):[\\/](.*)$", path.strip())
|
||||
found = re.match(r"(?i)^([a-z]):[\\/](.*)$", path.strip())
|
||||
if not found:
|
||||
return None
|
||||
parts = [part for part in re.split(r"[\\/]+", found.group(2)) if part]
|
||||
if not parts or ".." in parts:
|
||||
if ".." in parts:
|
||||
return None
|
||||
return os.path.join(drive_c(root) if found.group(1).lower() == "c" else "/", *parts)
|
||||
letter = found.group(1).lower()
|
||||
link = os.path.join(dosdevices(root), letter + ":")
|
||||
if letter == "c":
|
||||
target = drive_c(root)
|
||||
elif os.path.islink(link):
|
||||
target = os.path.realpath(link)
|
||||
elif letter == "z":
|
||||
target = "/"
|
||||
else:
|
||||
return None
|
||||
return os.path.join(target, *parts)
|
||||
|
||||
|
||||
def varint(data, at):
|
||||
value = shift = 0
|
||||
while at < len(data) and shift < 64:
|
||||
byte = data[at]
|
||||
at += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if not byte & 0x80:
|
||||
return value, at
|
||||
shift += 7
|
||||
raise ValueError("varint")
|
||||
|
||||
|
||||
def encode_varint(value):
|
||||
out = bytearray()
|
||||
while value > 0x7F:
|
||||
out.append(value & 0x7F | 0x80)
|
||||
value >>= 7
|
||||
out.append(value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rewrite_field(data, path, change):
|
||||
out, at = bytearray(), 0
|
||||
while at < len(data):
|
||||
start = at
|
||||
tag, at = varint(data, at)
|
||||
kind = tag & 7
|
||||
if kind == 0:
|
||||
at = varint(data, at)[1]
|
||||
elif kind == 1:
|
||||
at += 8
|
||||
elif kind == 5:
|
||||
at += 4
|
||||
elif kind == 2:
|
||||
size, body = varint(data, at)
|
||||
at = body + size
|
||||
else:
|
||||
raise ValueError("wire type")
|
||||
if at > len(data):
|
||||
raise ValueError("length")
|
||||
if kind == 2 and tag >> 3 == path[0]:
|
||||
value = data[body:at]
|
||||
value = change(value) if len(path) == 1 else rewrite_field(value, path[1:], change)
|
||||
out += encode_varint(tag) + encode_varint(len(value)) + value
|
||||
else:
|
||||
out += data[start:at]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def drive_folder(root, value):
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
if not re.match(r"(?i)^z:[\\/]", text):
|
||||
return value
|
||||
folder = windows_path(root, text)
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return value
|
||||
moved = install_path(root, folder)
|
||||
return value if moved.startswith("Z:") else moved.replace("\\", "/").encode("utf-8")
|
||||
|
||||
|
||||
def move_installs(root):
|
||||
path = os.path.join(drive_c(root), *PRODUCT_DB)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
moved = rewrite_field(data, INSTALL_FOLDER, lambda value: drive_folder(root, value))
|
||||
if moved == data or wine_processes(os.path.join(prefix(root), "pfx")):
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
if f.read() != data:
|
||||
return False
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
with open(path + ".tmp", "wb") as f:
|
||||
f.write(moved)
|
||||
shutil.copymode(path, path + ".tmp")
|
||||
os.replace(path + ".tmp", path)
|
||||
return True
|
||||
|
||||
|
||||
def wow_folders(root):
|
||||
@@ -533,6 +693,11 @@ def configure(root, library=None):
|
||||
except OSError as error:
|
||||
say("Battle.net's config not written: %s" % error)
|
||||
return
|
||||
try:
|
||||
if move_installs(root):
|
||||
say("Battle.net's installed games on a mapped volume now use its Wine drive")
|
||||
except OSError as error:
|
||||
say("Battle.net's installed games not moved to their Wine drive: %s" % error)
|
||||
try:
|
||||
wallpaper(root)
|
||||
except OSError as error:
|
||||
@@ -1227,7 +1392,6 @@ def agent_job(root, action, uid, product="", folder="", locale="enUS", country="
|
||||
say("%s not made: %s" % (folder, error))
|
||||
emit("ERROR", "folder")
|
||||
return 2
|
||||
windows = "Z:" + folder
|
||||
told = None
|
||||
while True:
|
||||
processes = wine_processes(pfx)
|
||||
@@ -1244,6 +1408,8 @@ def agent_job(root, action, uid, product="", folder="", locale="enUS", country="
|
||||
time.sleep(POLL if poll is None else poll)
|
||||
with lock:
|
||||
stop_leftovers(pfx)
|
||||
if action == "install":
|
||||
windows = install_path(root, folder)
|
||||
emit("STATE", "starting")
|
||||
base = os.path.join(CACHE, "agent-%s" % uid)
|
||||
for suffix in (".json", ".game", ".form", ".error"):
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
|
||||
CHROMIUM_FILES = {"chrome-sandbox", "chrome_100_percent.pak", "v8_context_snapshot.bin"}
|
||||
D3D9_DEFAULT_MEMORY_MB = 4096
|
||||
SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
|
||||
SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
|
||||
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
|
||||
@@ -144,6 +145,12 @@ def deep_rock_gdk_raytracing(game):
|
||||
return {}, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"], "Deep Rock Galactic GDK: ray tracing off (D3D12 pipeline creation fails on Thor)"
|
||||
|
||||
|
||||
@fix
|
||||
def ff7_rebirth_feature_level(game):
|
||||
if game.stem in ("ff7rebirth", "ff7rebirth_"):
|
||||
return {"VKD3D_FEATURE_LEVEL": "12_2"}, [], "Final Fantasy VII Rebirth: D3D12 feature level 12_2 (the device-reported level fails its DX12 check)"
|
||||
|
||||
|
||||
@fix
|
||||
def arkham_city_x87(game):
|
||||
if "batmanac.exe" in game.files:
|
||||
@@ -396,7 +403,9 @@ def vram_cap(env):
|
||||
if not re.fullmatch(r"[1-9][0-9]{0,6}", value):
|
||||
return env
|
||||
result = env.copy()
|
||||
cap = "dxgi.maxDeviceMemory = %d" % int(value)
|
||||
cap = "dxgi.maxDeviceMemory = {0}".format(int(value))
|
||||
if int(value) < D3D9_DEFAULT_MEMORY_MB:
|
||||
cap += "; d3d9.maxAvailableMemory = {0}".format(int(value))
|
||||
own = result.get("DXVK_CONFIG", "").strip().strip(";").strip()
|
||||
result["DXVK_CONFIG"] = cap + "; " + own if own else cap
|
||||
result["DROIDDECK_VRAM_CAP"] = "1"
|
||||
|
||||
@@ -17,6 +17,7 @@ PROTON_RUN = os.path.join(BIN, "droiddeck-proton-run")
|
||||
APPIMAGE_RUN = os.path.join(BIN, "droiddeck-appimage-run")
|
||||
SCRIPT_RUN = os.path.join(BIN, "droiddeck-script-run")
|
||||
STEAM_LAUNCH = os.path.join(BIN, "droiddeck-steam-launch")
|
||||
STORE_LAUNCH = os.path.join(BIN, "droiddeck-store-launch")
|
||||
PROGRAMS = os.path.join(BIN, "droiddeck-desktop-programs")
|
||||
CACHE = os.path.join(HOME, ".cache", "droiddeck")
|
||||
IMPORTS = {"appimage": ("/opt/appimages/user", "app"), "folder": ("/opt/droiddeck-apps", "entry")}
|
||||
@@ -272,12 +273,32 @@ def steam_title(path):
|
||||
return None
|
||||
|
||||
|
||||
def exe_prefix(path):
|
||||
try:
|
||||
run = runpy.run_path(PROTON_RUN)
|
||||
root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or run["STEAM_ROOT"]
|
||||
target = run["locate"](path, root)
|
||||
return ["--prefix", target.appid, "--compatdata", target.compatdata]
|
||||
except (OSError, ValueError, KeyError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def store_launcher(path):
|
||||
try:
|
||||
return runpy.run_path(STORE_LAUNCH)["store_launcher"](path)
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def command(path, args, what, window=False, appid=None):
|
||||
folder = os.path.dirname(path)
|
||||
if what == "steam":
|
||||
return [STEAM_LAUNCH, "desktop", appid], folder
|
||||
if what == "windows":
|
||||
return [PROTON_RUN] + (["--window"] if window else []) + [path] + args, folder
|
||||
launcher = None if args else store_launcher(path)
|
||||
prefix = exe_prefix(path) if launcher else None
|
||||
keep = prefix or []
|
||||
return [PROTON_RUN] + (["--window"] if window else []) + keep + [launcher or path] + args, folder
|
||||
if what == "x86 script":
|
||||
return [FEX, "run", "--mode", "on", "--for", folder, "--"] + interpreter(path) + [path] + args, folder
|
||||
if what == "script":
|
||||
|
||||
@@ -315,8 +315,8 @@ def locate(program, root):
|
||||
os.path.dirname(program), root, "own")
|
||||
|
||||
|
||||
def fixed(program, root, appid):
|
||||
return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)),
|
||||
def fixed(program, root, appid, compatdata=None):
|
||||
return Target(appid, compatdata or os.path.join(root, "steamapps", "compatdata", str(appid)),
|
||||
os.path.dirname(program), root, "fixed")
|
||||
|
||||
|
||||
@@ -451,6 +451,7 @@ def arguments(program, args, desktop=None, compatdata=None):
|
||||
def main(argv):
|
||||
window = False
|
||||
prefix = None
|
||||
compatdata = None
|
||||
verb = VERB
|
||||
game_id = None
|
||||
desktop = None
|
||||
@@ -461,6 +462,8 @@ def main(argv):
|
||||
window = True
|
||||
elif option == "--prefix" and argv and prefix_id(argv[0]):
|
||||
prefix = argv.pop(0)
|
||||
elif option == "--compatdata" and argv and os.path.isabs(argv[0]):
|
||||
compatdata = argv.pop(0)
|
||||
elif option == "--verb" and argv and argv[0] in VERBS:
|
||||
verb = argv.pop(0)
|
||||
elif option == "--game-id" and argv and argv[0].isdigit() and argv[0] != "0":
|
||||
@@ -484,7 +487,7 @@ def main(argv):
|
||||
say("%s is not an app id" % app)
|
||||
return 2
|
||||
if not argv and app is None:
|
||||
say("usage: droiddeck-proton-run [--window] [--prefix ID] [--verb run] [--game-id ID] [--desktop WxH] PROGRAM [ARGS...]")
|
||||
say("usage: droiddeck-proton-run [--window] [--prefix ID [--compatdata DIR]] [--verb run] [--game-id ID] [--desktop WxH] PROGRAM [ARGS...]")
|
||||
return 2
|
||||
root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or STEAM_ROOT
|
||||
if options is None:
|
||||
@@ -509,7 +512,7 @@ def main(argv):
|
||||
if app is not None and int(app) >= STEAM_APP_LIMIT:
|
||||
target = shortcut_target(app, program, start, root)
|
||||
elif prefix:
|
||||
target = fixed(program, root, prefix)
|
||||
target = fixed(program, root, prefix, compatdata)
|
||||
else:
|
||||
target = locate(program, root)
|
||||
available = tools(root)
|
||||
|
||||
@@ -132,8 +132,7 @@ clear_stale_tmp() {
|
||||
[ -z "${BL_INSIDE:-}" ] && clear_stale_tmp
|
||||
if [ -z "${BL_INSIDE:-}" ]; then
|
||||
if [ "${BL_ZRAM:-0}" = 1 ]; then printf 0 > /tmp/.bl-zram; else rm -f /tmp/.bl-zram; fi
|
||||
rm -f /tmp/.bl-clock
|
||||
head -c 8 /dev/zero > /tmp/.bl-clock
|
||||
dd if=/dev/zero of=/tmp/.bl-clock bs=8 count=1 conv=notrunc status=none
|
||||
fi
|
||||
|
||||
steam_devtools() {
|
||||
@@ -812,19 +811,30 @@ case $mode in
|
||||
fi
|
||||
}
|
||||
bl_zram_usage() {
|
||||
local _p _k _v _u _rss=0 _swap=0
|
||||
local _p _k _v _u _resident _anonymous _rss=0 _anon=0 _swap=0
|
||||
for _p in "$@"; do
|
||||
_resident=0 _anonymous=-1
|
||||
while read -r _k _v _u; do
|
||||
case $_k in VmRSS:) _rss=$((_rss + _v)) ;; VmSwap:) _swap=$((_swap + _v)) ;; esac
|
||||
case $_v in ''|*[!0-9]*) continue ;; esac
|
||||
case $_k in
|
||||
VmRSS:) _resident=$_v ;;
|
||||
RssAnon:) _anonymous=$_v ;;
|
||||
VmSwap:) _swap=$((_swap + _v)) ;;
|
||||
esac
|
||||
done 2>/dev/null < "/proc/$_p/status"
|
||||
[ "$_anonymous" -ge 0 ] || _anonymous=$_resident
|
||||
_rss=$((_rss + _resident)) _anon=$((_anon + _anonymous))
|
||||
done
|
||||
_bl_rss=$_rss
|
||||
_bl_rss=$_rss _bl_anon=$_anon
|
||||
_bl_swap=$_swap
|
||||
echo "$# processes, resident $((_rss / 1024)) MB, in zram $((_swap / 1024)) MB"
|
||||
}
|
||||
bl_memory_read() {
|
||||
local _k _v _u _kind _fields _avg
|
||||
_bl_mem_total=0 _bl_available=-1 _bl_swap_total=0 _bl_swap_free=-1 _bl_psi=0
|
||||
local _k _v _u _kind _fields _avg _whole _frac _gpu_bytes _growth _saved_growth _saved_fall
|
||||
_saved_growth=${_bl_preserved_growth:-0} _saved_fall=${_bl_preserved_fall:-0}
|
||||
_bl_preserved_growth=0 _bl_preserved_fall=0
|
||||
_bl_mem_total=0 _bl_available=-1 _bl_swap_total=0 _bl_swap_free=-1
|
||||
_bl_psi_some=0 _bl_psi_full=0 _bl_gpu=0 _bl_unevictable=0 _bl_shmem=0
|
||||
while read -r _k _v _u; do
|
||||
case $_v in ''|*[!0-9]*) continue ;; esac
|
||||
case $_k in
|
||||
@@ -832,15 +842,50 @@ case $mode in
|
||||
MemAvailable:) _bl_available=$_v ;;
|
||||
SwapTotal:) _bl_swap_total=$_v ;;
|
||||
SwapFree:) _bl_swap_free=$_v ;;
|
||||
Unevictable:) _bl_unevictable=$_v ;;
|
||||
Shmem:) _bl_shmem=$_v ;;
|
||||
esac
|
||||
done 2>/dev/null < /proc/meminfo
|
||||
if [ -r /proc/pressure/memory ]; then
|
||||
while read -r _kind _fields; do
|
||||
[ "$_kind" = some ] || continue
|
||||
_avg=${_fields#avg10=} _avg=${_avg%% *} _avg=${_avg%%.*}
|
||||
case $_avg in ''|*[!0-9]*) ;; *) _bl_psi=$((10#$_avg)) ;; esac
|
||||
[ "$_kind" = some ] || [ "$_kind" = full ] || continue
|
||||
_avg=${_fields#avg10=} _avg=${_avg%% *}
|
||||
_whole=${_avg%%.*} _frac=${_avg#*.}
|
||||
[ "$_frac" = "$_avg" ] && _frac=00
|
||||
_frac=${_frac}00
|
||||
_frac=${_frac:0:2}
|
||||
case $_whole$_frac in ''|*[!0-9]*) continue ;; esac
|
||||
if [ "$_kind" = some ]; then
|
||||
_bl_psi_some=$((10#$_whole * 100 + 10#$_frac))
|
||||
else
|
||||
_bl_psi_full=$((10#$_whole * 100 + 10#$_frac))
|
||||
fi
|
||||
done 2>/dev/null < /proc/pressure/memory
|
||||
fi
|
||||
if [ -r /sys/class/kgsl/kgsl/page_alloc ]; then
|
||||
read -r _gpu_bytes _u < /sys/class/kgsl/kgsl/page_alloc
|
||||
case $_gpu_bytes in ''|*[!0-9]*) _gpu_bytes=0 ;; esac
|
||||
_bl_gpu=$((_gpu_bytes / 1024))
|
||||
fi
|
||||
_bl_psi=$_bl_psi_some
|
||||
[ "$_bl_psi_full" -gt "$_bl_psi" ] && _bl_psi=$_bl_psi_full
|
||||
if [ "${_bl_sample_at:-}" = "" ]; then
|
||||
_bl_growth=0 _bl_fall=0
|
||||
_bl_sample_at=$SECONDS _bl_sample_available=$_bl_available _bl_sample_gpu=$_bl_gpu
|
||||
_bl_sample_unevictable=$_bl_unevictable _bl_sample_shmem=$_bl_shmem
|
||||
elif [ "$SECONDS" -gt "$_bl_sample_at" ]; then
|
||||
_bl_growth=0 _bl_fall=0
|
||||
_growth=$((_bl_gpu - _bl_sample_gpu)); [ "$_growth" -gt 0 ] && _bl_growth=$((_bl_growth + _growth))
|
||||
_growth=$((_bl_unevictable - _bl_sample_unevictable)); [ "$_growth" -gt 0 ] && _bl_growth=$((_bl_growth + _growth))
|
||||
_growth=$((_bl_shmem - _bl_sample_shmem)); [ "$_growth" -gt 0 ] && _bl_growth=$((_bl_growth + _growth))
|
||||
_bl_fall=$((_bl_sample_available - _bl_available))
|
||||
[ "$_saved_growth" -gt "$_bl_growth" ] && _bl_growth=$_saved_growth
|
||||
[ "$_saved_fall" -gt "$_bl_fall" ] && _bl_fall=$_saved_fall
|
||||
_bl_sample_at=$SECONDS _bl_sample_available=$_bl_available _bl_sample_gpu=$_bl_gpu
|
||||
_bl_sample_unevictable=$_bl_unevictable _bl_sample_shmem=$_bl_shmem
|
||||
fi
|
||||
_bl_real_available=$_bl_available
|
||||
[ "$_bl_real_available" -ge 0 ] && _bl_real_available=$((_bl_real_available - _bl_growth))
|
||||
_bl_headroom=$((_bl_mem_total / 5))
|
||||
[ "$_bl_headroom" -ge $((512 * 1024)) ] || _bl_headroom=$((512 * 1024))
|
||||
[ "$_bl_headroom" -le $((1536 * 1024)) ] || _bl_headroom=$((1536 * 1024))
|
||||
@@ -852,25 +897,29 @@ case $mode in
|
||||
}
|
||||
bl_memory_tight() {
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_available" -ge 0 ] &&
|
||||
{ [ "$_bl_available" -lt "$_bl_headroom" ] ||
|
||||
{ [ "$_bl_psi" -ge 1 ] && [ "$_bl_available" -lt $((_bl_mem_total / 3)) ]; }; }
|
||||
{ [ "$_bl_real_available" -lt "$_bl_headroom" ] ||
|
||||
{ [ "$_bl_psi" -ge 100 ] && [ "$_bl_real_available" -lt $((_bl_mem_total / 3)) ]; } ||
|
||||
{ [ "$_bl_fall" -ge $((256 * 1024)) ] && [ "$_bl_real_available" -lt $((_bl_headroom * 2)) ]; }; }
|
||||
}
|
||||
bl_client_can_pageout() {
|
||||
bl_memory_read
|
||||
bl_zram_usage "${_bl_pids[@]}" > /dev/null
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_swap_total" -gt 0 ] &&
|
||||
[ $((_bl_swap_free - _bl_rss)) -ge "$_bl_swap_reserve" ]
|
||||
[ "$_bl_mem_total" -gt 0 ] && bl_zram_has_room
|
||||
}
|
||||
bl_zram_has_room() {
|
||||
[ "$_bl_swap_total" -gt 0 ] &&
|
||||
[ $((_bl_swap_free - _bl_anon - ${_bl_client_reserve:-0})) -ge "$_bl_swap_reserve" ]
|
||||
}
|
||||
bl_client_can_readback() {
|
||||
bl_memory_read
|
||||
bl_zram_usage "${_bl_pids[@]}" > /dev/null
|
||||
[ "$_bl_mem_total" -gt 0 ] && ! bl_memory_tight &&
|
||||
[ "$_bl_available" -ge $((_bl_swap * 2)) ] &&
|
||||
[ $((_bl_available - _bl_swap)) -ge "$_bl_headroom" ]
|
||||
[ "$_bl_mem_total" -gt 0 ] && ! bl_memory_tight && ! bl_memory_critical &&
|
||||
[ "$_bl_real_available" -ge $((_bl_swap * 2)) ] &&
|
||||
[ $((_bl_real_available - _bl_swap)) -ge "$_bl_headroom" ]
|
||||
}
|
||||
bl_client_pressure() {
|
||||
[ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ "$_bl_game" = 1 ] &&
|
||||
[ "${#_bl_pids[@]}" -gt 0 ] && bl_memory_tight || return 0
|
||||
[ "${#_bl_pids[@]}" -gt 0 ] && { bl_memory_tight || bl_memory_critical; } || return 0
|
||||
[ "$_bl_pressure_at" -lt 0 ] ||
|
||||
[ $((SECONDS - _bl_pressure_at)) -ge "$_bl_pressure_backoff" ] || return 0
|
||||
[ "$_bl_packed" -lt 0 ] || [ $((SECONDS - _bl_packed)) -ge 30 ] || return 0
|
||||
@@ -880,12 +929,29 @@ case $mode in
|
||||
return 0
|
||||
fi
|
||||
[ "$_bl_rss" -ge $((128 * 1024)) ] || return 0
|
||||
local _before=$_bl_rss
|
||||
bl_zram_send 1 "memory pressure, compressing the client (available $((_bl_available / 1024)) MB, swap free $((_bl_swap_free / 1024)) MB)" client
|
||||
_bl_pressure_before=$_bl_rss
|
||||
if [ "${1:-}" = defer ]; then
|
||||
_bl_client_reserve=$_bl_anon
|
||||
_bl_client_pending=1
|
||||
fi
|
||||
bl_zram_send 1 "memory pressure, compressing the client (available $((_bl_available / 1024)) MB, swap free $((_bl_swap_free / 1024)) MB)" client "${1:-}"
|
||||
_bl_packed=$SECONDS
|
||||
if [ "${1:-}" = defer ]; then
|
||||
_bl_client_targets=("${_bl_zram_targets[@]}")
|
||||
else
|
||||
bl_client_pressure_result
|
||||
fi
|
||||
}
|
||||
bl_client_pressure_finish() {
|
||||
[ "${_bl_client_pending:-0}" = 1 ] || return 0
|
||||
bl_zram_finish 1 "${_bl_client_targets[@]}"
|
||||
bl_client_pressure_result "${_bl_client_targets[@]}"
|
||||
_bl_client_pending=0 _bl_client_reserve=0 _bl_client_targets=()
|
||||
}
|
||||
bl_client_pressure_result() {
|
||||
[ "${_bl_pageout_complete:-0}" = 1 ] || return 0
|
||||
bl_zram_usage "${_bl_pids[@]}" > /dev/null
|
||||
if [ $((_before - _bl_rss)) -lt $((16 * 1024)) ]; then
|
||||
if [ "$#" -gt 0 ]; then bl_zram_usage "$@" > /dev/null; else bl_zram_usage "${_bl_pids[@]}" > /dev/null; fi
|
||||
if [ $((_bl_pressure_before - _bl_rss)) -lt $((16 * 1024)) ]; then
|
||||
_bl_pressure_backoff=$((_bl_pressure_backoff * 2 > 300 ? 300 : _bl_pressure_backoff * 2))
|
||||
else
|
||||
_bl_pressure_backoff=30
|
||||
@@ -893,15 +959,13 @@ case $mode in
|
||||
}
|
||||
bl_memory_critical() {
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_available" -ge 0 ] &&
|
||||
{ [ "$_bl_available" -lt "$_bl_tier_headroom" ] ||
|
||||
{ [ "$_bl_psi" -ge 1 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 3 / 2)) ]; }; }
|
||||
}
|
||||
bl_low_ram() {
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_mem_total" -le $((13 * 1024 * 1024)) ]
|
||||
{ [ "$_bl_real_available" -lt "$_bl_tier_headroom" ] ||
|
||||
{ [ "$_bl_psi" -ge 100 ] && [ "$_bl_real_available" -lt $((_bl_tier_headroom * 3 / 2)) ]; } ||
|
||||
{ [ "$_bl_fall" -ge $((256 * 1024)) ] && [ "$_bl_real_available" -lt $((_bl_tier_headroom * 2)) ]; }; }
|
||||
}
|
||||
bl_session_pressure() {
|
||||
bl_memory_read
|
||||
[ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0
|
||||
[ "$_bl_game" = 1 ] && bl_memory_critical || return 0
|
||||
local _wait=$_bl_tier_backoff _before _pids _targets
|
||||
[ "$_bl_available" -ge $((_bl_headroom / 2)) ] || [ "$_bl_tier_pushed" -lt 0 ] || _wait=30
|
||||
[ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0
|
||||
@@ -917,21 +981,18 @@ case $mode in
|
||||
fi
|
||||
fi
|
||||
_bl_tier_at=$SECONDS _bl_tier_pushed=0
|
||||
bl_zram_pick game
|
||||
bl_zram_pick session
|
||||
bl_zram_usage "${_pids[@]}" > /dev/null
|
||||
[ "$_bl_rss" -ge $((256 * 1024)) ] || return 0
|
||||
if [ "$_bl_swap_total" -le 0 ] || [ $((_bl_swap_free - _bl_rss)) -lt "$_bl_swap_reserve" ]; then
|
||||
if ! bl_zram_has_room; then
|
||||
step "zRAM: low-memory page-out skipped, swap headroom too small ($((_bl_swap_free / 1024)) MB free)"
|
||||
return 0
|
||||
fi
|
||||
_targets=("${_pids[@]}") _before=$_bl_swap
|
||||
bl_zram_send 1 "low memory (available $((_bl_available / 1024)) MB), compressing idle game memory" game
|
||||
bl_zram_send 1 "low memory (available $((_bl_real_available / 1024)) MB), compressing idle game memory" session
|
||||
bl_zram_usage "${_targets[@]}" > /dev/null
|
||||
_bl_tier_pushed=$((_bl_swap - _before))
|
||||
bl_tier_note "${_targets[@]}"
|
||||
if [ "${_bl_pageout_complete:-0}" = 1 ] && [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ "${#_bl_pids[@]}" -gt 0 ]; then
|
||||
_bl_pressure_at=$SECONDS _bl_packed=$SECONDS
|
||||
fi
|
||||
}
|
||||
bl_proc_start() {
|
||||
local _s=
|
||||
@@ -962,7 +1023,7 @@ case $mode in
|
||||
}
|
||||
bl_watch_nap() {
|
||||
local _i
|
||||
if [ "$_bl_game" = 1 ] && bl_low_ram && [ "$_bl_available" -ge 0 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 2)) ]; then
|
||||
if [ "$_bl_game" = 1 ] && [ "$_bl_available" -ge 0 ] && [ "$_bl_real_available" -lt $((_bl_tier_headroom * 2)) ]; then
|
||||
for _i in 1 2 3 4 5; do
|
||||
nap 1
|
||||
bl_memory_read
|
||||
@@ -1018,10 +1079,16 @@ case $mode in
|
||||
for _p in "${_pids[@]}"; do rm -f "/tmp/.bl-zram.ack.$_p"; done
|
||||
printf '%s' "$_mode" > /tmp/.bl-zram
|
||||
[ "${#_pids[@]}" = 0 ] || kill -URG "${_pids[@]}" 2>/dev/null
|
||||
if [ "$_mode" = 1 ]; then bl_zram_wait "${_pids[@]}"; else nap 5; fi
|
||||
_bl_zram_targets=("${_pids[@]}")
|
||||
[ "${4:-}" != defer ] || return 0
|
||||
bl_zram_finish "$_mode" "${_pids[@]}"
|
||||
}
|
||||
bl_zram_finish() {
|
||||
local _mode=$1
|
||||
shift
|
||||
if [ "$_mode" = 1 ]; then bl_zram_wait "$@"; else nap 5; fi
|
||||
bl_zram_scan
|
||||
bl_zram_pick "$_which"
|
||||
step "zRAM: done ($(bl_zram_usage "${_pids[@]}"))"
|
||||
step "zRAM: done ($(bl_zram_usage "$@"))"
|
||||
}
|
||||
bl_game_focused() {
|
||||
if [ "${BL_DESKTOP:-}" = 1 ] || [ -z "${BL_LAUNCH_DIR:-}" ]; then
|
||||
@@ -1048,6 +1115,8 @@ case $mode in
|
||||
}
|
||||
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30
|
||||
_bl_pressure_at=-1 _bl_pressure_backoff=30 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
|
||||
_bl_client_pending=0 _bl_client_reserve=0 _bl_client_targets=()
|
||||
_bl_sample_at= _bl_sample_available=0 _bl_sample_gpu=0 _bl_sample_unevictable=0 _bl_sample_shmem=0
|
||||
while kill -0 $$ 2>/dev/null; do
|
||||
bl_zram_scan
|
||||
bl_memory_read
|
||||
@@ -1074,8 +1143,11 @@ case $mode in
|
||||
_bl_backoff=30
|
||||
fi
|
||||
fi
|
||||
_bl_preserved_growth=$_bl_growth _bl_preserved_fall=$_bl_fall
|
||||
bl_client_pressure defer
|
||||
_bl_preserved_growth=$_bl_growth _bl_preserved_fall=$_bl_fall
|
||||
bl_session_pressure
|
||||
bl_client_pressure
|
||||
bl_client_pressure_finish
|
||||
if [ "$_bl_game" = 1 ] && bl_game_focused; then
|
||||
if [ "$_bl_since" -lt 0 ] || [ "$_bl_since_apps" != "$_bl_apps" ]; then
|
||||
_bl_since=$SECONDS _bl_since_apps=$_bl_apps
|
||||
@@ -1396,6 +1468,8 @@ case $mode in
|
||||
decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; }
|
||||
trap decky_stop TERM INT HUP
|
||||
decky_exits=0
|
||||
# The guest has no "deck" user, which Decky otherwise requires at startup.
|
||||
export UNPRIVILEGED_USER=root
|
||||
while :; do
|
||||
if [ "$decky_arch" = x86_64 ]; then /usr/local/bin/droiddeck-fex run --mode on -- "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
|
||||
@@ -449,7 +449,11 @@ mkdir -p "$srt_libs"
|
||||
for d in "$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/lib/x86_64-linux-gnu \
|
||||
"$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/usr/lib/x86_64-linux-gnu; do
|
||||
[ -d "$d" ] || continue
|
||||
for f in "$d"/*.so*; do [ -e "$srt_libs/$(basename "$f")" ] || ln -s "$f" "$srt_libs/"; done
|
||||
for f in "$d"/*.so*; do
|
||||
[ -e "$f" ] || [ -L "$f" ] || continue
|
||||
s="$srt_libs/$(basename "$f")"
|
||||
{ [ -e "$s" ] || [ -L "$s" ]; } || ln -s "$f" "$srt_libs/"
|
||||
done
|
||||
done
|
||||
FEX_ROOTFS=$ROOTFS /usr/local/bin/FEX "$ROOTFS/usr/sbin/ldconfig.real" -n "$srt_libs" >>"$LOG" 2>&1 || step "ldconfig on $srt_libs failed"
|
||||
sniper_compat=$X64_HOME/sniper-compat
|
||||
@@ -458,6 +462,7 @@ mkdir -p "$sniper_compat"
|
||||
hb=$(ls "$srt_libs"/libharfbuzz.so.0.* 2>/dev/null | head -n 1)
|
||||
if [ -n "$hb" ]; then ln -s "$hb" "$sniper_compat/libharfbuzz.so.0"; else step "warning: no HarfBuzz in Valve's runtime for the newer-runtime titles"; fi
|
||||
for f in "$srt_libs"/*; do
|
||||
[ -e "$f" ] || [ -L "$f" ] || continue
|
||||
n=$(basename "$f")
|
||||
if [ -e "$ROOTFS/usr/lib/x86_64-linux-gnu/$n" ] || [ -e "$ROOTFS/lib/x86_64-linux-gnu/$n" ]; then rm -f "$f"; fi
|
||||
done
|
||||
|
||||
@@ -48,14 +48,14 @@ def sidecar_at(folder):
|
||||
return sidecar
|
||||
|
||||
|
||||
def find_store_game(argv):
|
||||
def find_store_game(argv, depth=MAX_DEPTH):
|
||||
for raw in argv:
|
||||
arg = raw.strip().strip('"').strip("'").replace("\\", "/")
|
||||
low = arg.lower()
|
||||
if not (low.endswith(LAUNCHER) or low.endswith(".exe") or low.endswith(".bat")) or "/" not in arg:
|
||||
continue
|
||||
folder = Path(arg).parent
|
||||
for _ in range(MAX_DEPTH):
|
||||
for _ in range(depth):
|
||||
sidecar = sidecar_at(folder)
|
||||
if sidecar is not None:
|
||||
return folder, sidecar
|
||||
@@ -65,6 +65,21 @@ def find_store_game(argv):
|
||||
return None
|
||||
|
||||
|
||||
def store_launcher(path):
|
||||
real = os.path.realpath(path)
|
||||
for start in dict.fromkeys([os.path.abspath(path), real]):
|
||||
found = find_store_game([start], depth=len(Path(start).parts))
|
||||
if found is None:
|
||||
continue
|
||||
folder, sidecar = found
|
||||
launcher, exe = str(sidecar.get("launcher") or ""), str(sidecar.get("exe") or "")
|
||||
if not launcher or not exe or (folder / launcher).is_symlink() or not (folder / launcher).is_file():
|
||||
continue
|
||||
if os.path.realpath(folder / exe).lower() == real.lower():
|
||||
return str(folder / launcher)
|
||||
return None
|
||||
|
||||
|
||||
def drop_stale(folder, now=None):
|
||||
code = folder / EPIC_CODE
|
||||
try:
|
||||
|
||||
@@ -1540,7 +1540,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
|
||||
auto = followers(state, current)
|
||||
known = {app for app in state.get("owned", ()) if isinstance(app, str)} if isinstance(state.get("owned"), list) else set()
|
||||
pinned = {app: current[app] for app in set(owned) - known
|
||||
if current.get(app) in {TOOL, previous} | gone and app not in auto}
|
||||
if app in current and current.get(app) in {TOOL, previous} | gone and app not in auto}
|
||||
if pinned and isinstance(auto, dict):
|
||||
state["auto"] = dict(auto, **pinned)
|
||||
state["owned"] = sorted(set(owned), key=app_order)
|
||||
|
||||
@@ -81,7 +81,6 @@ static void cp_init(void) {
|
||||
struct stat st;
|
||||
void *map;
|
||||
int fd;
|
||||
if (strcmp(program_invocation_short_name, "plasmashell") != 0) return;
|
||||
if (!cp_find_cond_bit()) return;
|
||||
fd = open(CP_CTL, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define DESCRIPTOR "user.wine.sd"
|
||||
#define STORE "/.droiddeck-descriptors"
|
||||
#define ENTRY_MAX (PATH_MAX * 2)
|
||||
|
||||
int bl_is_wineserver(void) __attribute__((visibility("hidden")));
|
||||
|
||||
static int store_seen;
|
||||
|
||||
static int kept(const char *name) {
|
||||
return name != NULL && strcmp(name, DESCRIPTOR) == 0 && bl_is_wineserver();
|
||||
}
|
||||
|
||||
static int store_root(char *out, size_t size) {
|
||||
const char *prefix = getenv("WINEPREFIX");
|
||||
int n;
|
||||
|
||||
if (prefix == NULL || prefix[0] != '/') return -1;
|
||||
n = snprintf(out, size, "%s" STORE, prefix);
|
||||
return n > 0 && (size_t)n < size ? n : -1;
|
||||
}
|
||||
|
||||
static int store_exists(void) {
|
||||
char root[PATH_MAX];
|
||||
|
||||
if (store_seen) return 1;
|
||||
if (store_root(root, sizeof(root)) < 0) return 0;
|
||||
store_seen = access(root, F_OK) == 0;
|
||||
return store_seen;
|
||||
}
|
||||
|
||||
static int entry_for(const char *path, char *out, size_t size, const char *suffix, int create) {
|
||||
int used = store_root(out, size);
|
||||
const char *part = path, *end;
|
||||
|
||||
if (used < 0 || path[0] != '/') return -1;
|
||||
if (create && mkdir(out, 0700) != 0 && errno != EEXIST) return -1;
|
||||
while (*part == '/') part++;
|
||||
if (*part == '\0') return -1;
|
||||
for (;;) {
|
||||
size_t length;
|
||||
const char *tail;
|
||||
|
||||
end = strchr(part, '/');
|
||||
length = end ? (size_t)(end - part) : strlen(part);
|
||||
tail = end ? ".d" : suffix;
|
||||
if (length + strlen(tail) > NAME_MAX || used + length + strlen(tail) + 2 > size) return -1;
|
||||
out[used++] = '/';
|
||||
memcpy(out + used, part, length);
|
||||
used += (int)length;
|
||||
strcpy(out + used, tail);
|
||||
used += (int)strlen(tail);
|
||||
if (!end) break;
|
||||
if (create && mkdir(out, 0700) != 0 && errno != EEXIST) return -1;
|
||||
part = end + 1;
|
||||
}
|
||||
if (create) store_seen = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int path_of(int fd, char *out, size_t size) {
|
||||
char link[40];
|
||||
ssize_t n;
|
||||
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
|
||||
n = readlink(link, out, size - 1);
|
||||
if (n <= 0 || out[0] != '/') return -1;
|
||||
out[n] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int resolved(const char *path, char *out, size_t size) {
|
||||
int fd, result;
|
||||
|
||||
if (path == NULL) return -1;
|
||||
fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
result = path_of(fd, out, size);
|
||||
close(fd);
|
||||
return result;
|
||||
}
|
||||
|
||||
static int entry_of(int fd, char *out, size_t size, int create) {
|
||||
char path[PATH_MAX];
|
||||
|
||||
if (path_of(fd, path, sizeof(path)) != 0) return -1;
|
||||
return entry_for(path, out, size, ".sd", create);
|
||||
}
|
||||
|
||||
static ssize_t stored_read(int fd, void *value, size_t size) {
|
||||
char entry[ENTRY_MAX];
|
||||
struct stat st;
|
||||
ssize_t got = 0;
|
||||
int file;
|
||||
|
||||
if (entry_of(fd, entry, sizeof(entry), 0) != 0) return -2;
|
||||
file = open(entry, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (file < 0) {
|
||||
if (errno != ENOENT) return -2;
|
||||
errno = ENODATA;
|
||||
return -1;
|
||||
}
|
||||
if (fstat(file, &st) != 0) {
|
||||
close(file);
|
||||
return -2;
|
||||
}
|
||||
if (size == 0) {
|
||||
close(file);
|
||||
return (ssize_t)st.st_size;
|
||||
}
|
||||
if ((size_t)st.st_size > size) {
|
||||
close(file);
|
||||
errno = ERANGE;
|
||||
return -1;
|
||||
}
|
||||
while (got < st.st_size) {
|
||||
ssize_t n = read(file, (char *)value + got, (size_t)(st.st_size - got));
|
||||
if (n <= 0) {
|
||||
close(file);
|
||||
return -2;
|
||||
}
|
||||
got += n;
|
||||
}
|
||||
close(file);
|
||||
return got;
|
||||
}
|
||||
|
||||
static int stored_write(int fd, const void *value, size_t size) {
|
||||
char entry[ENTRY_MAX], fresh[ENTRY_MAX + 8];
|
||||
size_t put = 0;
|
||||
int file;
|
||||
|
||||
if (entry_of(fd, entry, sizeof(entry), 1) != 0) return -1;
|
||||
snprintf(fresh, sizeof(fresh), "%s.new", entry);
|
||||
file = open(fresh, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
if (file < 0) return -1;
|
||||
while (put < size) {
|
||||
ssize_t n = write(file, (const char *)value + put, size - put);
|
||||
if (n <= 0) {
|
||||
close(file);
|
||||
unlinkat(AT_FDCWD, fresh, 0);
|
||||
return -1;
|
||||
}
|
||||
put += (size_t)n;
|
||||
}
|
||||
if (close(file) != 0 || renameat(AT_FDCWD, fresh, AT_FDCWD, entry) != 0) {
|
||||
unlinkat(AT_FDCWD, fresh, 0);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
ssize_t fgetxattr(int fd, const char *name, void *value, size_t size) {
|
||||
static ssize_t (*next)(int, const char *, void *, size_t);
|
||||
ssize_t result;
|
||||
|
||||
if (next == NULL) next = dlsym(RTLD_NEXT, "fgetxattr");
|
||||
if (next == NULL) {
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
result = next(fd, name, value, size);
|
||||
if (result >= 0 || errno != ENOTSUP || !kept(name)) return result;
|
||||
result = stored_read(fd, value, size);
|
||||
if (result == -2) {
|
||||
errno = ENOTSUP;
|
||||
return -1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int fsetxattr(int fd, const char *name, const void *value, size_t size, int flags) {
|
||||
static int (*next)(int, const char *, const void *, size_t, int);
|
||||
int saved = errno, result;
|
||||
|
||||
if (next == NULL) next = dlsym(RTLD_NEXT, "fsetxattr");
|
||||
if (next == NULL) {
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
result = next(fd, name, value, size, flags);
|
||||
if (result == 0 || errno != ENOTSUP || !kept(name)) return result;
|
||||
if (stored_write(fd, value, size) != 0) {
|
||||
errno = ENOTSUP;
|
||||
return -1;
|
||||
}
|
||||
errno = saved;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int fremovexattr(int fd, const char *name) {
|
||||
static int (*next)(int, const char *);
|
||||
char entry[ENTRY_MAX];
|
||||
int saved = errno, result;
|
||||
|
||||
if (next == NULL) next = dlsym(RTLD_NEXT, "fremovexattr");
|
||||
if (next == NULL) {
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
result = next(fd, name);
|
||||
if (result == 0 || errno != ENOTSUP || !kept(name)) return result;
|
||||
if (entry_of(fd, entry, sizeof(entry), 0) != 0) {
|
||||
errno = ENOTSUP;
|
||||
return -1;
|
||||
}
|
||||
if (unlinkat(AT_FDCWD, entry, 0) != 0) {
|
||||
errno = errno == ENOENT ? ENODATA : ENOTSUP;
|
||||
return -1;
|
||||
}
|
||||
errno = saved;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int tracked(const char *path, char *out, size_t size) {
|
||||
int saved = errno, found;
|
||||
|
||||
found = bl_is_wineserver() && store_exists() && resolved(path, out, size) == 0;
|
||||
errno = saved;
|
||||
return found;
|
||||
}
|
||||
|
||||
static void forget(const char *path, int folder) {
|
||||
char entry[ENTRY_MAX];
|
||||
int saved = errno;
|
||||
|
||||
if (entry_for(path, entry, sizeof(entry), ".sd", 0) == 0) unlinkat(AT_FDCWD, entry, 0);
|
||||
if (folder && entry_for(path, entry, sizeof(entry), ".d", 0) == 0) unlinkat(AT_FDCWD, entry, AT_REMOVEDIR);
|
||||
errno = saved;
|
||||
}
|
||||
|
||||
static void follow(const char *from, const char *to) {
|
||||
static const char *const kinds[] = {".sd", ".d"};
|
||||
char old[ENTRY_MAX], new[ENTRY_MAX];
|
||||
int saved = errno;
|
||||
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
if (entry_for(from, old, sizeof(old), kinds[i], 0) != 0) continue;
|
||||
if (access(old, F_OK) != 0) {
|
||||
if (i == 0 && entry_for(to, new, sizeof(new), kinds[i], 0) == 0) unlinkat(AT_FDCWD, new, 0);
|
||||
continue;
|
||||
}
|
||||
if (entry_for(to, new, sizeof(new), kinds[i], 1) != 0 || renameat(AT_FDCWD, old, AT_FDCWD, new) != 0) {
|
||||
if (i == 0) unlinkat(AT_FDCWD, old, 0);
|
||||
}
|
||||
}
|
||||
errno = saved;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
int bl_descriptor_unlink(const char *path, int (*real)(const char *)) {
|
||||
char target[PATH_MAX];
|
||||
int known = tracked(path, target, sizeof(target)), result = real(path);
|
||||
|
||||
if (result == 0 && known) forget(target, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
int bl_descriptor_rmdir(const char *path, int (*real)(const char *)) {
|
||||
char target[PATH_MAX];
|
||||
int known = tracked(path, target, sizeof(target)), result = real(path);
|
||||
|
||||
if (result == 0 && known) forget(target, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
int bl_descriptor_remove(const char *path, int (*real)(const char *)) {
|
||||
return bl_descriptor_rmdir(path, real);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
int bl_descriptor_rename(const char *from, const char *to, int (*real)(const char *, const char *)) {
|
||||
char old[PATH_MAX], new[PATH_MAX];
|
||||
int known = tracked(from, old, sizeof(old)), result = real(from, to);
|
||||
|
||||
if (result == 0 && known && tracked(to, new, sizeof(new))) follow(old, new);
|
||||
return result;
|
||||
}
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user