Merge remote-tracking branch 'droiddeck/main' into feat/refresh-loading-tips

This commit is contained in:
Kurt Himebauch committed 2026-11-12 14:34:56 +00:00
commit 27aec13910
119 files changed
+3571 -255

No files matched your search

+2 -3
View File
@@ -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
+2
View File
@@ -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)
}
+2
View File
@@ -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();
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+4
View File
@@ -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() {
+27 -19
View File
@@ -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 -1
View File
@@ -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.
+9
View File
@@ -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:
+25 -6
View File
@@ -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
View File
@@ -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)
-1
View File
@@ -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;
+291
View File
@@ -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