diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 555d8b673..57a207514 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 28bf184e3..35201d85c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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) } diff --git a/app/src/main/assets/agent/droiddeckctl b/app/src/main/assets/agent/droiddeckctl new file mode 100755 index 000000000..a72cb2587 --- /dev/null +++ b/app/src/main/assets/agent/droiddeckctl @@ -0,0 +1,2 @@ +#!/bin/sh +exec python3 /root/.local/share/droiddeck/ctl/droiddeckctl "$@" diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 61baf0d37..c7aa1b0eb 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -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 diff --git a/app/src/main/cpp/waylandcomp/src/clock_domain.c b/app/src/main/cpp/waylandcomp/src/clock_domain.c new file mode 100644 index 000000000..30fd73a4e --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/clock_domain.c @@ -0,0 +1,72 @@ +#define _GNU_SOURCE +#include "clock_domain.h" + +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/app/src/main/cpp/waylandcomp/src/clock_domain.h b/app/src/main/cpp/waylandcomp/src/clock_domain.h new file mode 100644 index 000000000..acb7e0e17 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/clock_domain.h @@ -0,0 +1,11 @@ +#ifndef DROIDDECK_CLOCK_DOMAIN_H +#define DROIDDECK_CLOCK_DOMAIN_H + +#include + +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 diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 21ef1d23f..3510ea29a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -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, diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index ffbfdfd93..25b97ae0f 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -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; diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 79f42d118..ac9995412 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 55fdf3f1f..699dd9643 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt index b1e939b08..d82ea8f12 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt index b9971ced1..f5c080a06 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentTab.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentTab.kt new file mode 100644 index 000000000..7392473c7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentTab.kt @@ -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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt index d3d5cf2c7..9e0452771 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt @@ -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, 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 -> diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt index 075719246..114e33789 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -13,6 +13,11 @@ object HostProcess { @JvmStatic fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?, outputFile: File?): Int { + return start(command, env, dir, onExit, onLine, outputFile, null) + } + + @JvmStatic + fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?, 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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt index b9c0256db..c7b4a78c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt @@ -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) + } + } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index c7dd27a27..c75602585 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -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 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; } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt index a98e3d0cf..e1dd1fff2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt @@ -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*$""") diff --git a/app/src/main/java/com/droiddeck/launcher/session/BuildWatermarkSetting.kt b/app/src/main/java/com/droiddeck/launcher/session/BuildWatermarkSetting.kt new file mode 100644 index 000000000..98239f8eb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BuildWatermarkSetting.kt @@ -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 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)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 5bf25103b..e9ecb7443 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -90,11 +90,11 @@ object GameStorage { fun gamesFolderLibraries(context: Context): List { 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt index cbcd904bc..a6c511766 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt @@ -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 = 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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index cb766a2de..3ac0ef8ab 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 4b0a94f7b..a14490bb8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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 = diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 4faa87086..232eaf0c6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index b0a685214..ad7360d8e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index 67f013b78..756b05f45 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt index 084676359..8b219470c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamLibraries.kt @@ -82,13 +82,14 @@ object SteamLibraries { /** Reads storage; call off the main thread. */ fun found(context: Context): List { 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>() 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>() @@ -101,6 +102,9 @@ object SteamLibraries { } } + internal fun offered(taken: List, gamesFolders: List, 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 diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index de3648e83..2111bfdee 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt index 9c5fff91a..3a7c52db3 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt @@ -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 } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt index 9590ce4a0..5be0d0393 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt @@ -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\"").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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt index df0799102..686ebd3c8 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt @@ -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 { 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() val components = HashMap() + val listings = HashMap>>() 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 } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt b/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt index a9d0c3d1a..446420806 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BuildWatermark.kt @@ -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() + internal var lineProvider: () -> List? = { 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(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(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) : View(context) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 5358b221e..351eded54 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 31a8fb28c..7638e8f04 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 79ce76913..f90cf1b26 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index a0d75f027..cb438903b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -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.entries.filter { (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) && diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index 04ff93efc..68bcb8d27 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 990d085f1..e0622acf5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -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() } + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 8ebfb5ad6..a685e414f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 716644002..7fcdc0962 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index a2ac2171d..995f51247 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -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(); diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fe2c8b170..38304b2e4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2694,6 +2694,8 @@ هذا الجهاز إزالة %1$s؟ التشخيص + علامة مائية لاسم الإصدار + عرض اسم إصدار الاختبار أو Lab على الشاشة. مفعّل افتراضيًا. جاهز للّعب غير جاهز للّعب بعد بيئة التشغيل %1$s diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 6c63e9403..57fe0e9c7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2631,6 +2631,8 @@ Este dispositivo ¿Quitar %1$s? Diagnóstico + Marca de agua de compilación + Muestra en pantalla la etiqueta de la versión de prueba o Lab. Activada de forma predeterminada. Listo para jugar Aún no está listo para jugar runtime %1$s diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 70e8ea121..c8cee1463 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2586,6 +2586,8 @@ Това устройство Премахване на %1$s? Диагностика + Воден знак с етикет на компилацията + Показва етикета на тестовата или Lab компилация на екрана. Включено по подразбиране. Готово за игра Още не е готово за игра среда %1$s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 225a15a10..03578b9d1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2640,6 +2640,8 @@ Toto zařízení Odstranit %1$s? Diagnostika + Vodoznak s označením sestavení + Zobrazit na obrazovce označení testovacího sestavení nebo sestavení Lab. Ve výchozím nastavení zapnuté. Připraveno ke hraní Ještě není připraveno ke hraní runtime %1$s diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 9a9859e11..1cd48d818 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2586,6 +2586,8 @@ Denne enhed Fjern %1$s? Diagnostik + Vandmærke med buildmærkat + Vis mærkatet for test- eller Lab-builden på skærmen. Aktiveret som standard. Klar til at spille Ikke klar til at spille endnu runtime %1$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 61cb3c6f0..252e79a81 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2575,6 +2575,8 @@ Dieses Gerät %1$s entfernen? Diagnose + Build-Kennzeichnung als Wasserzeichen + Kennzeichnung des Test- oder Lab-Builds auf dem Bildschirm anzeigen. Standardmäßig aktiviert. Bereit zum Spielen Noch nicht bereit zum Spielen Laufzeit %1$s diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 7fa9c9a8d..4cbb15326 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2586,6 +2586,8 @@ Αυτή η συσκευή Κατάργηση %1$s; Διαγνωστικά + Υδατογράφημα ετικέτας έκδοσης + Εμφάνιση της ετικέτας δοκιμαστικής έκδοσης ή έκδοσης Lab στην οθόνη. Ενεργό από προεπιλογή. Έτοιμο για παιχνίδι Δεν είναι ακόμα έτοιμο για παιχνίδι runtime %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 96d08e381..5283682c6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2610,6 +2610,8 @@ Este dispositivo ¿Quitar %1$s? Diagnóstico + Marca de agua de compilación + Muestra en pantalla la etiqueta de la versión de prueba o Lab. Activada de forma predeterminada. Listo para jugar Aún no está listo para jugar runtime %1$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index e42db8e1a..e2de49316 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2586,6 +2586,8 @@ Tämä laite Poistetaanko %1$s? Diagnostiikka + Koontiversion vesileima + Näytä testi- tai Lab-version tunniste näytöllä. Käytössä oletusarvoisesti. Valmis pelattavaksi Ei vielä valmis pelattavaksi ajoympäristö %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f6af3c1ef..d55804fc1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2610,6 +2610,8 @@ Cet appareil Supprimer %1$s ? Diagnostic + Filigrane de version + Afficher le libellé de la version de test ou Lab à l’écran. Activé par défaut. Prêt à jouer Pas encore prêt à jouer environnement d\'exécution %1$s diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e55c54203..9c98d3248 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2586,6 +2586,8 @@ यह डिवाइस %1$s हटाएं? डायग्नोस्टिक्स + बिल्ड लेबल वॉटरमार्क + स्क्रीन पर टेस्ट या Lab बिल्ड लेबल दिखाएँ। डिफ़ॉल्ट रूप से चालू। खेलने के लिए तैयार अभी खेलने के लिए तैयार नहीं रनटाइम %1$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 40e1948bc..7ce5caef0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2586,6 +2586,8 @@ Ez a készülék Eltávolítja a %1$s-t? Diagnosztika + Buildcímke vízjelként + A tesztbuild vagy Lab-build címkéjének megjelenítése a képernyőn. Alapértelmezés szerint bekapcsolva. Készen áll a játékra Még nem áll készen a játékra futtatókörnyezet: %1$s diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 48ba6459d..e9e0be418 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -2559,6 +2559,8 @@ Perangkat ini Hapus %1$s? Diagnostik + Tanda air label build + Tampilkan label build pengujian atau Lab di layar. Aktif secara default. Siap bermain Belum siap bermain runtime %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f0bddcb10..934b22230 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2602,6 +2602,8 @@ Questo dispositivo Rimuovere %1$s? Diagnostica + Filigrana con etichetta build + Mostra sullo schermo l’etichetta della build di test o Lab. Attiva per impostazione predefinita. Pronto per giocare Non ancora pronto per giocare runtime %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e24984c33..5ea8b5d6a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2559,6 +2559,8 @@ このデバイス %1$sを削除しますか? 診断 + ビルドラベルの透かし + テストビルドまたはLabビルドのラベルを画面に表示します。デフォルトで有効です。 プレイの準備完了 まだプレイの準備ができていません 実行環境 %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a797a770e..1e9c92d5d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2559,6 +2559,8 @@ 이 기기 %1$s을(를) 제거하시겠습니까? 진단 + 빌드 라벨 워터마크 + 테스트 또는 Lab 빌드 라벨을 화면에 표시합니다. 기본적으로 켜져 있습니다. 플레이 준비 완료 아직 플레이할 준비가 되지 않음 런타임 %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index d750f6378..1ed7061f9 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2559,6 +2559,8 @@ Peranti ini Alih keluar %1$s? Diagnostik + Tera air label binaan + Paparkan label binaan ujian atau Lab pada skrin. Dihidupkan secara lalai. Sedia untuk bermain Belum sedia untuk bermain masa jalan %1$s diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 2493762b9..01c7e1b2e 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2586,6 +2586,8 @@ Denne enheten Fjerne %1$s? Diagnostikk + Vannmerke for byggetikett + Vis etiketten for test- eller Lab-bygget på skjermen. På som standard. Klar til å spille Ikke klar til å spille ennå kjøretid %1$s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b13e0a663..d2e99be69 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2586,6 +2586,8 @@ Dit apparaat %1$s verwijderen? Diagnostiek + Buildlabel als watermerk + Toon het label van de testbuild of Lab-build op het scherm. Standaard ingeschakeld. Klaar om te spelen Nog niet klaar om te spelen runtime %1$s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index da4c41dff..d917c805a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2640,6 +2640,8 @@ To urządzenie Usunąć %1$s? Diagnostyka + Znak wodny z etykietą kompilacji + Pokazuj na ekranie etykietę kompilacji testowej lub Lab. Domyślnie włączone. Gotowe do gry Jeszcze nie gotowe do gry środowisko %1$s diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 792343cee..e1cac1044 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2602,6 +2602,8 @@ Este dispositivo Remover %1$s? Diagnóstico + Marca de água da compilação + Mostrar no ecrã a etiqueta da compilação de teste ou Lab. Ativada por predefinição. Pronto para jogar Ainda não está pronto para jogar ambiente de execução %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 101b423ec..19af42532 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2602,6 +2602,8 @@ Este aparelho Remover %1$s? Diagnóstico + Marca de água da compilação + Mostrar na tela a etiqueta da compilação de teste ou Lab. Ativada por padrão. Pronto para jogar Ainda não está pronto para jogar runtime %1$s diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 9821fccbf..469f5ffa7 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2613,6 +2613,8 @@ Acest dispozitiv Elimini %1$s? Diagnosticare + Filigrană cu eticheta versiunii + Afișează pe ecran eticheta versiunii de testare sau Lab. Activată implicit. Pregătit de joc Încă nepregătit de joc mediu de execuție %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 61ffd3710..785e86661 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2720,6 +2720,8 @@ Это устройство Удалить %1$s? Диагностика + Водяной знак с меткой сборки + Показывать на экране метку тестовой сборки или сборки Lab. Включено по умолчанию. Готово к игре Пока не готово к игре среда выполнения %1$s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index f9e7239a6..9c1150d31 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2586,6 +2586,8 @@ Den här enheten Ta bort %1$s? Diagnostik + Vattenstämpel med byggetikett + Visa testbyggets eller Lab-byggets etikett på skärmen. Aktiverad som standard. Redo att spela Inte redo att spela än runtime %1$s diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 040d90cb6..7c21d4416 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -2559,6 +2559,8 @@ อุปกรณ์นี้ ลบ %1$s? การวินิจฉัย + ลายน้ำป้ายชื่อบิลด์ + แสดงป้ายชื่อบิลด์ทดสอบหรือบิลด์ Lab บนหน้าจอ เปิดไว้โดยค่าเริ่มต้น พร้อมเล่นแล้ว ยังไม่พร้อมเล่น รันไทม์ %1$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 6cef0499d..7f2fef4d3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2575,6 +2575,8 @@ Bu cihaz %1$s kaldırılsın mı? Tanılama + Derleme etiketi filigranı + Test veya Lab derlemesinin etiketini ekranda gösterir. Varsayılan olarak açıktır. Oynamaya hazır Henüz oynamaya hazır değil çalışma ortamı %1$s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 979ad0436..4e117c117 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2640,6 +2640,8 @@ Цей пристрій Видалити %1$s? Діагностика + Водяний знак із міткою збірки + Показувати на екрані мітку тестової збірки або збірки Lab. Увімкнено за замовчуванням. Готово до гри Ще не готово до гри середовище %1$s diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index f16bde404..b6153c2eb 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -2559,6 +2559,8 @@ Thiết bị này Xóa %1$s? Chẩn đoán + Hình mờ nhãn bản dựng + 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. Sẵn sàng để chơi Chưa sẵn sàng để chơi runtime %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 92d5f63f4..e296dbde3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2572,6 +2572,8 @@ 使用 %1$s? 移除 %1$s? 诊断 + 构建标签水印 + 在屏幕上显示测试版或 Lab 构建标签。默认启用。 可以游玩了 暂时还不能游玩 运行时 %1$s diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 048c9dab2..156c6067e 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2559,6 +2559,8 @@ 此裝置 移除 %1$s? 診斷 + 建置標籤浮水印 + 在畫面上顯示測試版或 Lab 建置標籤。預設啟用。 可以開始遊戲 尚未可以開始遊戲 運行環境 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4bf6d5ee1..b97cd5dfe 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2559,6 +2559,8 @@ 此裝置 要移除 %1$s 嗎? 診斷 + 組建標籤浮水印 + 在畫面上顯示測試版或 Lab 組建標籤。預設啟用。 已可開始遊戲 尚未準備好開始遊戲 執行環境 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72473108b..c6f62ed32 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -939,6 +939,8 @@ Saved after each session Enhanced diagnostics for next session Captures detailed logs for one session. May reduce performance and use more storage. Turns itself off when the session starts; review logs before sharing. + Build label watermark + Show the test or Lab build label on screen. On by default. Share diagnostic bundles Session and device Graphics @@ -2659,6 +2661,7 @@ Sort Sidebar labels Show names under the sidebar icons. Turn off for a slimmer sidebar. + Agent tab Display Larger cards Smaller cards @@ -3357,6 +3360,7 @@ VC++ and DirectX installers games ask for Runs Linux x86 games Steam tool + Steam downloads this for games that ship anti-cheat, even when it is off. Removing it stalls their install at 0% DroidDeck runs games without this runtime x86 Proton, which DroidDeck cannot run Default for every game diff --git a/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt index 80c6ebd32..c367c63ec 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt @@ -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() }) diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt index 0b8c930ed..cff58d665 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt @@ -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))) diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt index a76da590a..ea66f3213 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt @@ -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 } } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt index 2a2bade1c..ae46c9d5e 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt @@ -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]) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt index 1af73baef..c9a3fcb9e 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt @@ -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() } diff --git a/app/src/test/java/com/droiddeck/launcher/session/BuildWatermarkSettingTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BuildWatermarkSettingTest.kt new file mode 100644 index 000000000..f495d9e0b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BuildWatermarkSettingTest.kt @@ -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("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("build-watermark")) + activity.recreate() + assertNull(activity.get().window.decorView.findViewWithTag("build-watermark")) + SessionPrefs.setBuildWatermarkEnabled(context, true) + shadowOf(Looper.getMainLooper()).idle() + assertNotNull(activity.get().window.decorView.findViewWithTag("build-watermark")) + } finally { + activity.pause().stop().destroy() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt index 230eb1f12..44aa6c73c 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -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>() val pagedIn = mutableListOf>() 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) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt index 5a3fdb26c..5317e5ca3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamLibrariesTest.kt @@ -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 }) diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt index 0cfbf2e05..a3b6a8fa2 100644 --- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -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"))) } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt index a52485035..bb8aa159a 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt @@ -40,6 +40,43 @@ class EaTest { assertNull(EaCrypto.challengeSeed("9", answer)) } + @Test fun onlyTokenRejectionsAreRetried() { + val rejected = EaReply(409, "".toByteArray(), emptyMap()) + val notEntitled = EaReply(409, "".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, "".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 = "kh" + "12349tokgsn" diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt index 06c88bfd1..c98c1da30 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt @@ -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() { diff --git a/docs/development/low-memory.md b/docs/development/low-memory.md index 4b8e9b459..81055ff0b 100644 --- a/docs/development/low-memory.md +++ b/docs/development/low-memory.md @@ -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, diff --git a/docs/development/vram-budget.md b/docs/development/vram-budget.md index c9e6820ef..50e3becf7 100644 --- a/docs/development/vram-budget.md +++ b/docs/development/vram-budget.md @@ -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. diff --git a/docs/lab-builds.md b/docs/lab-builds.md index 4f2ddfb5f..2e01e3b9d 100644 --- a/docs/lab-builds.md +++ b/docs/lab-builds.md @@ -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: diff --git a/tools/agent/dd-lab-build b/tools/agent/dd-lab-build index 44b62bd6b..b29c6bc5a 100755 --- a/tools/agent/dd-lab-build +++ b/tools/agent/dd-lab-build @@ -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 diff --git a/tools/agent/setup b/tools/agent/setup index 0b1f66f6a..5c2405d8d 100755 --- a/tools/agent/setup +++ b/tools/agent/setup @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet b/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet index 1ea487e87..3473e7dea 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet @@ -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"): diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 175b8d8d1..459e8a6fa 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -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" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-open b/tools/linuxfs/overlay/usr/local/bin/droiddeck-open index bf0aaff7c..d39942a2c 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-open +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-open @@ -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": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run index 164dbd028..6dfcbadb2 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index df301aada..9ec12398f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -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 & diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index 481d25d19..0aeb0e594 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index ca6ddebc8..939504835 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -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: diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 426b6c173..cf616c86f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -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) diff --git a/tools/linuxfs/preload/clockpause.c b/tools/linuxfs/preload/clockpause.c index 19a1d728a..eed7a401d 100644 --- a/tools/linuxfs/preload/clockpause.c +++ b/tools/linuxfs/preload/clockpause.c @@ -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; diff --git a/tools/linuxfs/preload/descriptor.c b/tools/linuxfs/preload/descriptor.c new file mode 100644 index 000000000..681dce999 --- /dev/null +++ b/tools/linuxfs/preload/descriptor.c @@ -0,0 +1,291 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/tools/linuxfs/preload/dircache.c b/tools/linuxfs/preload/dircache.c index 296faa616..f6951497f 100644 --- a/tools/linuxfs/preload/dircache.c +++ b/tools/linuxfs/preload/dircache.c @@ -846,9 +846,17 @@ int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode, } ONE_PATH(mkdir, ADDED, IN_CREATE, next_mkdir(path, mode), mode_t mode) -ONE_PATH(rmdir, REMOVED, IN_DELETE, next_rmdir(path)) -ONE_PATH(unlink, REMOVED, IN_DELETE, next_unlink(path)) -ONE_PATH(remove, REMOVED, IN_DELETE, next_remove(path)) +int bl_descriptor_unlink(const char *path, int (*real)(const char *)) __attribute__((visibility("hidden"))); +int bl_descriptor_rmdir(const char *path, int (*real)(const char *)) __attribute__((visibility("hidden"))); +int bl_descriptor_remove(const char *path, int (*real)(const char *)) __attribute__((visibility("hidden"))); +int bl_descriptor_rename(const char *from, const char *to, int (*real)(const char *, const char *)) + __attribute__((visibility("hidden"))); + +static int moved(const char *from, const char *to) { return bl_descriptor_rename(from, to, next_rename); } + +ONE_PATH(rmdir, REMOVED, IN_DELETE, bl_descriptor_rmdir(path, next_rmdir)) +ONE_PATH(unlink, REMOVED, IN_DELETE, bl_descriptor_unlink(path, next_unlink)) +ONE_PATH(remove, REMOVED, IN_DELETE, bl_descriptor_remove(path, next_remove)) #define TWO_PATHS(name, gone, event, call) \ int name(const char *from, const char *to) { \ @@ -881,7 +889,7 @@ ONE_PATH(remove, REMOVED, IN_DELETE, next_remove(path)) int bl_link_lock(const char *oldpath, const char *newpath, int result) __attribute__((visibility("hidden"))); static int link_or_lock(const char *from, const char *to) { return bl_link_lock(from, to, next_link(from, to)); } -TWO_PATHS(rename, 1, IN_MOVED_TO, next_rename) +TWO_PATHS(rename, 1, IN_MOVED_TO, moved) TWO_PATHS(link, 0, IN_CREATE, link_or_lock) TWO_PATHS(symlink, 0, IN_CREATE, next_symlink) diff --git a/tools/linuxfs/preload/owner.c b/tools/linuxfs/preload/owner.c index 78ada3bf0..1e493f8a8 100644 --- a/tools/linuxfs/preload/owner.c +++ b/tools/linuxfs/preload/owner.c @@ -12,7 +12,8 @@ #define FAT_MAGIC 0x4d44 #define EXFAT_MAGIC 0x2011bab0 -static int is_wineserver(void) { +__attribute__((visibility("hidden"))) +int bl_is_wineserver(void) { static int cached = -1; char exe[PATH_MAX]; const char *base; @@ -43,7 +44,7 @@ void bl_storage_owner(int fd, struct stat *st) { int saved = errno; uid_t uid; - if (st != NULL && is_wineserver()) { + if (st != NULL && bl_is_wineserver()) { uid = getuid(); if (st->st_uid != uid && has_no_unix_owner(fd)) st->st_uid = uid; } @@ -60,7 +61,7 @@ int fchmod(int fd, mode_t mode) { return -1; } result = next_fchmod(fd, mode); - if (result != 0 && errno == EPERM && is_wineserver()) { + if (result != 0 && errno == EPERM && bl_is_wineserver()) { if (has_no_unix_owner(fd)) { errno = saved; return 0; diff --git a/tools/linuxfs/preload/venus.c b/tools/linuxfs/preload/venus.c index 4feb9ee03..aae9ebbec 100644 --- a/tools/linuxfs/preload/venus.c +++ b/tools/linuxfs/preload/venus.c @@ -12,7 +12,14 @@ #include #include +extern char **environ; + __attribute__((constructor)) static void bl_venus_keep_kopper(void) { + static const char name[] = "LIBGL_KOPPER_DISABLE="; const char *debug = getenv("VN_DEBUG"); - if (debug && strstr(debug, "vtest")) unsetenv("LIBGL_KOPPER_DISABLE"); + if (!debug || !strstr(debug, "vtest") || !environ) return; + char **out = environ; + for (char **in = environ; *in; in++) + if (strncmp(*in, name, sizeof name - 1)) *out++ = *in; + *out = NULL; } diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index c4173a48c..3099009c5 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -162,6 +162,11 @@ Prototype (inert unless `PROOT_FASTPATH` is set; see `docs/development/proot-per `ENOSYS`, because its arguments are host paths; the fast path then stops for that process and repeats the call through a plain `svc`, which the guest's filter traps with the guest path. `bench/guest-trap.c` installs a tagged `openat` trap and checks its handler answers the call. + The kernel and filesystems also return `ENOSYS` themselves: Android's FUSE daemon has no + `symlink`, so `symlinkat` on a storage card fails with it. The fast path therefore asks again + with the same call number and invalid arguments. A trap answers `ENOSYS` again and the fast path + stops for the process; any other error means the call reached the kernel, so only that one call + is repeated through a plain `svc` and the fast path stays on. Old kernel fixes: diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c index 8d645eddd..bcc745852 100644 --- a/tools/proot/fastpath/fastpath.c +++ b/tools/proot/fastpath/fastpath.c @@ -41,7 +41,7 @@ typedef long (*stub_fn)(long nr, long a, long b, long c, long d, long e, long f) static stub_fn fp_sys; static int fp_on; -struct bind { char *host; size_t hlen; char *guest; size_t glen; int canon; int dropped; }; +struct bind { char *host; size_t hlen; char *guest; size_t glen; int canon; int dropped; int fuse; }; static struct bind binds[MAX_BINDS]; static int nbinds; static char root[PATH_MAX]; @@ -59,10 +59,10 @@ static int optional_syscall(long nr) { nr == SYS_openat2 || nr == SYS_renameat2; } static long sc(long nr, long a, long b, long c, long d, long e) { - if (fp_refused) return -ENOSYS; + if (fp_refused && nr != SYS_close) return -ENOSYS; long r = fp_sys(nr, a, b, c, d, e, 0); if (r == -ENOSYS && !optional_syscall(nr)) { - __atomic_store_n(&fp_on, 0, __ATOMIC_RELAXED); + if (fp_sys(nr, -1, -1, -1, -1, -1, 0) == -ENOSYS) __atomic_store_n(&fp_on, 0, __ATOMIC_RELAXED); fp_refused = 1; } return r; @@ -133,7 +133,8 @@ static long tracer; static pthread_mutex_t cache_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t bind_lock; static pthread_mutex_t long_lock; -static void after_fork(void) { pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&bind_lock, NULL); pthread_mutex_init(&long_lock, NULL); } +static pthread_mutex_t dir_lock = PTHREAD_MUTEX_INITIALIZER; +static void after_fork(void) { pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&bind_lock, NULL); pthread_mutex_init(&long_lock, NULL); pthread_mutex_init(&dir_lock, NULL); } static int traced_by_match(const char *key) { static char buf[32768]; @@ -414,6 +415,212 @@ static int host_bind(const char *h) { return -1; } +static unsigned hash_of(const char *s); +static long long now_ns(void); +#define DIRS 64 +#define DIR_FD_FLOOR 1000 +struct dirslot { unsigned hash, epoch; int fd, refs; dev_t dev; ino_t ino; long tag; long long stamp; char path[PATH_MAX]; char seen[PATH_MAX]; }; +static struct dirslot dirs[DIRS]; +static unsigned dir_epoch; +struct near { long fd; int slot, own; const char *name; }; + +static int fuse_bind(const char *h) { + int i = host_bind(h); + if (i < 0) return -1; + int f = __atomic_load_n(&binds[i].fuse, __ATOMIC_ACQUIRE); + if (!f) { + struct statfs fs; + f = sc(SYS_statfs, (long)binds[i].host, (long)&fs, 0, 0, 0) == 0 && fs.f_type == 0x65735546 ? 1 : 2; + __atomic_store_n(&binds[i].fuse, f, __ATOMIC_RELEASE); + } + return f == 1 ? i : -1; +} + +static long fd_path(long fd, char *out) { + char link[40]; + snprintf(link, sizeof link, "/proc/self/fd/%ld", fd); + long n = sc(SYS_readlinkat, AT_FDCWD, (long)link, (long)out, PATH_MAX - 1, 0); + if (n <= 0 || n >= PATH_MAX - 1) return -1; + out[n] = 0; + return 0; +} + +static int fd_id(long fd, dev_t *dev, ino_t *ino) { + struct stat st; + if (sc(SYS_newfstatat, fd, (long)"", (long)&st, AT_EMPTY_PATH, 0) != 0) return -1; + *dev = st.st_dev; + *ino = st.st_ino; + return 0; +} + +static long dir_tag(void) { + static uint64_t state; + uint64_t z = __atomic_add_fetch(&state, 0x9e3779b97f4a7c15ULL, __ATOMIC_RELAXED), seed = 0; + const void *random = (const void *)getauxval(AT_RANDOM); + if (random) memcpy(&seed, random, sizeof seed); + z += seed; + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL; + z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL; + return (long)((z ^ (z >> 31)) & 0xffffffffffULL) + 1; +} + +static int dir_owned(const struct dirslot *e) { + dev_t dev; + ino_t ino; + return e->fd > 0 && sc(SYS_lseek, e->fd, 0, SEEK_CUR, 0, 0) == e->tag + && fd_id(e->fd, &dev, &ino) == 0 && dev == e->dev && ino == e->ino; +} + +static int dir_fresh(struct dirslot *e, const char *dir) { + long long t = now_ns(); + if (t - __atomic_load_n(&e->stamp, __ATOMIC_RELAXED) < ttl_ns) return 1; + struct stat st; + if (sc(SYS_newfstatat, AT_FDCWD, (long)dir, (long)&st, 0, 0) != 0 || st.st_dev != e->dev || st.st_ino != e->ino) { + __atomic_add_fetch(&dir_epoch, 1, __ATOMIC_RELEASE); + return 0; + } + __atomic_store_n(&e->stamp, t, __ATOMIC_RELAXED); + return 1; +} + +static void dirs_drop(const char *host) { + size_t len = strlen(host); + if (pthread_mutex_trylock(&dir_lock) != 0) { + __atomic_add_fetch(&dir_epoch, 1, __ATOMIC_RELEASE); + return; + } + for (int i = 0; i < DIRS; i++) + if (dirs[i].path[0] && under(dirs[i].path, host, len)) dirs[i].path[0] = 0; + pthread_mutex_unlock(&dir_lock); +} + +static void near_put(struct near *n) { + if (n->own) sc(SYS_close, n->fd, 0, 0, 0, 0); + else if (n->slot >= 0) __atomic_sub_fetch(&dirs[n->slot].refs, 1, __ATOMIC_RELEASE); + n->fd = AT_FDCWD; + n->slot = -1; + n->own = 0; +} + +static int dir_cached(const char *dir, struct near *n) { + unsigned h = hash_of(dir); + struct dirslot *e = &dirs[h % DIRS]; + if (pthread_mutex_trylock(&dir_lock) != 0) return 0; + int hit = e->fd >= 0 && e->hash == h && e->path[0] && strcmp(e->path, dir) == 0 + && e->epoch == __atomic_load_n(&dir_epoch, __ATOMIC_ACQUIRE); + if (hit) __atomic_add_fetch(&e->refs, 1, __ATOMIC_ACQUIRE); + pthread_mutex_unlock(&dir_lock); + if (!hit) return 0; + char now[PATH_MAX]; + if (fd_path(e->fd, now) == 0 && strcmp(now, e->seen) == 0 && dir_fresh(e, dir)) { + n->fd = e->fd; + n->slot = (int)(e - dirs); + n->own = 0; + return 1; + } + __atomic_sub_fetch(&e->refs, 1, __ATOMIC_RELEASE); + if (pthread_mutex_trylock(&dir_lock) == 0) { + if (e->hash == h && strcmp(e->path, dir) == 0) e->path[0] = 0; + pthread_mutex_unlock(&dir_lock); + } + return 0; +} + +static int dir_keep(const char *dir, long fd, struct near *n) { + long high = sc(SYS_fcntl, fd, F_DUPFD_CLOEXEC, DIR_FD_FLOOR, 0, 0); + sc(SYS_close, fd, 0, 0, 0, 0); + if (high < 0) return -1; + n->fd = high; + n->slot = -1; + n->own = 1; + char seen[PATH_MAX]; + dev_t dev; + ino_t ino; + long tag = dir_tag(); + struct stat st; + if (fd_id(high, &dev, &ino) < 0 || strlen(dir) >= PATH_MAX + || sc(SYS_newfstatat, AT_FDCWD, (long)dir, (long)&st, 0, 0) != 0 || st.st_dev != dev || st.st_ino != ino) { + near_put(n); + return -1; + } + if (fd_path(high, seen) < 0 || sc(SYS_lseek, high, tag, SEEK_SET, 0, 0) != tag) return 0; + unsigned h = hash_of(dir); + struct dirslot *e = &dirs[h % DIRS]; + if (pthread_mutex_trylock(&dir_lock) != 0) return 0; + if (__atomic_load_n(&e->refs, __ATOMIC_ACQUIRE) == 0) { + if (dir_owned(e)) sc(SYS_close, e->fd, 0, 0, 0, 0); + e->hash = h; + e->epoch = __atomic_load_n(&dir_epoch, __ATOMIC_ACQUIRE); + e->tag = tag; + e->stamp = now_ns(); + e->fd = (int)high; + e->dev = dev; + e->ino = ino; + e->refs = 1; + strcpy(e->path, dir); + strcpy(e->seen, seen); + n->slot = (int)(e - dirs); + n->own = 0; + } + pthread_mutex_unlock(&dir_lock); + return 0; +} + +static int dir_get(char *dir, size_t floor, struct near *n) { + size_t len = strlen(dir), at = len; + while (!dir_cached(dir, n)) { + if (at <= floor) { + long fd = sc(SYS_openat, AT_FDCWD, (long)dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC, 0, 0); + if (fd < 0 || dir_keep(dir, fd, n) < 0) return -1; + break; + } + char *slash = strrchr(dir, '/'); + if (!slash || (size_t)(slash - dir) < floor) return -1; + *slash = 0; + at = slash - dir; + } + while (at < len) { + dir[at] = '/'; + const char *name = dir + at + 1; + size_t next = at + 1 + strlen(name); + long fd = sc(SYS_openat, n->fd, (long)name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, 0, 0); + near_put(n); + if (fd < 0 || dir_keep(dir, fd, n) < 0) return -1; + at = next; + } + return 0; +} + +static long near_get(const char *host, struct near *n) { + n->fd = AT_FDCWD; + n->slot = -1; + n->own = 0; + n->name = host; + if (ttl_ns <= 0 || host[0] != '/') return n->fd; + int i = fuse_bind(host); + if (i < 0) return n->fd; + const char *slash = strrchr(host, '/'); + size_t plen = slash - host; + if (plen < binds[i].hlen || !slash[1] || plen >= PATH_MAX) return n->fd; + char dir[PATH_MAX]; + memcpy(dir, host, plen); + dir[plen] = 0; + if (dir_get(dir, binds[i].hlen, n) < 0) { + near_put(n); + return n->fd; + } + n->name = slash + 1; + return n->fd; +} + +static long hsc(long nr, const char *host, long a, long b, long c) { + struct near n; + long fd = near_get(host, &n); + long rs = sc(nr, fd, (long)n.name, a, b, c); + near_put(&n); + return rs; +} + static int guest_text(const char *h, char *g) { if (h[0] == '/' && under(h, root, rootlen)) return to_guest(h, g, PATH_MAX); int i = h[0] == '/' ? host_bind(h) : -1; @@ -556,7 +763,7 @@ static long proc_readlink(const struct res *r, char *text, size_t size, int *cha return name[0] == 'e' ? own_exe(text) : own_cwd(text); } char h[PATH_MAX]; - long t = sc(SYS_readlinkat, AT_FDCWD, (long)r->host, (long)h, sizeof h - 1, 0); + long t = hsc(SYS_readlinkat, r->host, (long)h, sizeof h - 1, 0); if (t < 0) return t; if (t >= (long)sizeof h - 1) return FP_SLOW; if (size && (size_t)t > size) t = size; @@ -579,7 +786,7 @@ static long proc_link(struct res *r, char *text, int final) { if (!under(r->host, "/proc", 5)) return FP_SLOW; r->moving = 1; if (!strcmp(r->guest, "/proc/self") || !strcmp(r->guest, "/proc/thread-self")) { - long t = sc(SYS_readlinkat, AT_FDCWD, (long)r->host, (long)text, PATH_MAX - 1, 0); + long t = hsc(SYS_readlinkat, r->host, (long)text, PATH_MAX - 1, 0); if (t <= 0 || t >= PATH_MAX - 1) return FP_SLOW; text[t] = 0; return t; @@ -628,7 +835,7 @@ static long walk(struct res *r, const char *text, int follow, int last) { if (reserved(r->guest)) return FP_SLOW; struct bind *through; if (to_host(r->guest, r->host, &through) < 0) return FP_SLOW; - long s = sc(SYS_newfstatat, AT_FDCWD, (long)r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0); + long s = hsc(SYS_newfstatat, r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0); if (s < 0) { int glue = glue_above_binding(r->guest, glen + n); if (glue < 0) return FP_SLOW; @@ -648,13 +855,13 @@ static long walk(struct res *r, const char *text, int follow, int last) { t = proc_link(r, target, final && last); if (t == FP_SLOW) return FP_SLOW; if (t == 0) { - if (sc(SYS_newfstatat, AT_FDCWD, (long)r->host, (long)&r->st, 0, 0) < 0) return FP_SLOW; + if (hsc(SYS_newfstatat, r->host, (long)&r->st, 0, 0) < 0) return FP_SLOW; r->magic = 1; r->exists = 1; return 0; } } else { - t = sc(SYS_readlinkat, AT_FDCWD, (long)r->host, (long)target, sizeof target - 1, 0); + t = hsc(SYS_readlinkat, r->host, (long)target, sizeof target - 1, 0); if (t <= 0 || t >= (long)sizeof target - 1) return FP_SLOW; target[t] = 0; if (detranslated(target, r->host) == FP_SLOW) return FP_SLOW; @@ -676,7 +883,7 @@ static long walk(struct res *r, const char *text, int follow, int last) { } if (!stated) { if (to_host(r->guest, r->host, NULL) < 0) return FP_SLOW; - long s = sc(SYS_newfstatat, AT_FDCWD, (long)r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0); + long s = hsc(SYS_newfstatat, r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0); if (s < 0 && strcmp(r->guest, "/") != 0 && glue_above_binding(r->guest, strlen(r->guest))) return FP_SLOW; if (s < 0) return s == -ENOENT ? FP_SLOW : s; } @@ -699,7 +906,7 @@ static long bound_literal(const char *literal, struct res *r) { struct bind *bd; if (reserved(literal) || bind_of(literal, &bd) < 0 || !bd || bd->glen != strlen(literal) || bd->glen < 2) return FP_SLOW; if (snprintf(r->host, PATH_MAX, "%s", bd->host) >= PATH_MAX) return FP_SLOW; - if (sc(SYS_newfstatat, AT_FDCWD, (long)r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0) < 0 || S_ISLNK(r->st.st_mode)) return FP_SLOW; + if (hsc(SYS_newfstatat, r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0) < 0 || S_ISLNK(r->st.st_mode)) return FP_SLOW; snprintf(r->guest, PATH_MAX, "%s", literal); r->exists = 1; r->moving = 0; @@ -822,8 +1029,8 @@ static long fp_open(int dirfd, const char *path, int flags, mode_t mode) { long rs = resolve(dirfd, path, follow | MAY_BE_UNSEEN, &r); if (rs < 0) return rs; if (rs == 1 && !(flags & O_CREAT)) return -ENOENT; - if (rs == UNSEEN) return sc(SYS_openat, AT_FDCWD, (long)r.host, flags, (flags & O_CREAT) ? mode : 0, 0); - return sc(SYS_openat, AT_FDCWD, (long)r.host, flags | (follow && !r.magic ? O_NOFOLLOW : 0), (flags & O_CREAT) ? mode : 0, 0); + if (rs == UNSEEN) return hsc(SYS_openat, r.host, flags, (flags & O_CREAT) ? mode : 0, 0); + return hsc(SYS_openat, r.host, flags | (follow && !r.magic ? O_NOFOLLOW : 0), (flags & O_CREAT) ? mode : 0, 0); } static int do_openat(int dirfd, const char *path, int flags, mode_t mode, int (*real)(int, const char *, int, ...)) { @@ -864,7 +1071,7 @@ static long fp_stat(int dirfd, const char *path, int flags, struct stat *st) { long rs = resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r); if (rs < 0) return rs; if (rs == 1) return -ENOENT; - if (rs == UNSEEN) return sc(SYS_newfstatat, AT_FDCWD, (long)r.host, (long)st, flags, 0); + if (rs == UNSEEN) return hsc(SYS_newfstatat, r.host, (long)st, flags, 0); *st = r.st; return 0; } @@ -1007,7 +1214,7 @@ static long fp_access(int dirfd, const char *path, int mode, int flags) { ? resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r) : FP_SLOW; if (rs == 1) rs = -ENOENT; if (rs == 0 && S_ISLNK(r.st.st_mode)) rs = FP_SLOW; - if (rs == 0 || rs == UNSEEN) rs = sc(SYS_faccessat, AT_FDCWD, (long)r.host, mode, 0, 0); + if (rs == 0 || rs == UNSEEN) rs = hsc(SYS_faccessat, r.host, mode, 0, 0); return rs; } @@ -1030,7 +1237,7 @@ static long link_text(const struct res *r, char *text, size_t size, int *changed *changed = 0; if (in_proc != under(r->host, "/proc", 5)) return FP_SLOW; if (in_proc) return proc_readlink(r, text, size, changed); - long t = sc(SYS_readlinkat, AT_FDCWD, (long)r->host, (long)text, PATH_MAX - 1, 0); + long t = hsc(SYS_readlinkat, r->host, (long)text, PATH_MAX - 1, 0); if (t < 0) return t; if (t >= PATH_MAX - 1) return FP_SLOW; if (size && (size_t)t > size) t = size; @@ -1135,7 +1342,7 @@ char *__getcwd_chk(char *buf, size_t size, size_t room) { static long fp_mkdir(int dirfd, const char *path, mode_t mode) { struct res r; long rs = resolve(dirfd, path, MAY_BE_UNSEEN, &r); - if (rs == 0 || rs == 1 || rs == UNSEEN) rs = sc(SYS_mkdirat, AT_FDCWD, (long)r.host, mode, 0, 0); + if (rs == 0 || rs == 1 || rs == UNSEEN) rs = hsc(SYS_mkdirat, r.host, mode, 0, 0); return rs; } int mkdirat(int dirfd, const char *path, mode_t mode) { @@ -1172,7 +1379,7 @@ static long fp_unlink(int dirfd, const char *path, int flags) { if (rs == 0 && (flags & AT_REMOVEDIR) && strcmp(r.guest, "/") == 0) rs = FP_SLOW; if (rs == 0 || rs == UNSEEN) { int changes_paths = (flags & AT_REMOVEDIR) || rs == UNSEEN || S_ISLNK(r.st.st_mode); - rs = sc(SYS_unlinkat, AT_FDCWD, (long)r.host, flags, 0, 0); + rs = hsc(SYS_unlinkat, r.host, flags, 0, 0); if (rs == 0 && changes_paths) forget(); } return rs; @@ -1196,9 +1403,14 @@ static long fp_rename(int olddirfd, const char *old, int newdirfd, const char *n if (rt < 0) rs = rt; else if (rt == 0 && S_ISDIR(to.st.st_mode)) rs = FP_SLOW; else { - rs = flags ? sc(SYS_renameat2, AT_FDCWD, (long)from.host, AT_FDCWD, (long)to.host, flags) - : sc(SYS_renameat, AT_FDCWD, (long)from.host, AT_FDCWD, (long)to.host, 0); - if (rs == 0) forget(); + struct near a, b; + near_get(from.host, &a); + near_get(to.host, &b); + rs = flags ? sc(SYS_renameat2, a.fd, (long)a.name, b.fd, (long)b.name, flags) + : sc(SYS_renameat, a.fd, (long)a.name, b.fd, (long)b.name, 0); + near_put(&a); + near_put(&b); + if (rs == 0 && (S_ISLNK(from.st.st_mode) || (rt == 0 && S_ISLNK(to.st.st_mode)))) forget(); } } return rs; @@ -1236,7 +1448,10 @@ static long fp_chmod(int dirfd, const char *path, mode_t mode, int flags) { struct res r; long rs = flags ? FP_SLOW : resolve(dirfd, path, 1 | MAY_BE_UNSEEN, &r); if (rs == 1) rs = -ENOENT; - if (rs == 0 || rs == UNSEEN) rs = sc(SYS_fchmodat, AT_FDCWD, (long)r.host, mode, 0, 0); + if (rs == 0 || rs == UNSEEN) { + rs = hsc(SYS_fchmodat, r.host, mode, 0, 0); + if (rs == 0) dirs_drop(r.host); + } return rs; } int fchmodat(int dirfd, const char *path, mode_t mode, int flags) { @@ -1249,7 +1464,10 @@ static long fp_chown(int dirfd, const char *path, uid_t uid, gid_t gid, int flag struct res r; long rs = flags & ~AT_SYMLINK_NOFOLLOW ? FP_SLOW : resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r); if (rs == 1) rs = -ENOENT; - if (rs == 0 || rs == UNSEEN) rs = sc(SYS_fchownat, AT_FDCWD, (long)r.host, (long)uid, (long)gid, flags); + if (rs == 0 || rs == UNSEEN) { + rs = hsc(SYS_fchownat, r.host, (long)uid, (long)gid, flags); + if (rs == 0) dirs_drop(r.host); + } return rs; } int fchownat(int dirfd, const char *path, uid_t uid, gid_t gid, int flags) { @@ -1267,7 +1485,7 @@ static long fp_utimens(int dirfd, const char *path, const struct timespec times[ } else { rs = flags & ~AT_SYMLINK_NOFOLLOW ? FP_SLOW : resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r); if (rs == 1) rs = -ENOENT; - if (rs == 0 || rs == UNSEEN) rs = sc(SYS_utimensat, AT_FDCWD, (long)r.host, (long)times, flags, 0); + if (rs == 0 || rs == UNSEEN) rs = hsc(SYS_utimensat, r.host, (long)times, flags, 0); } return rs; } @@ -1509,7 +1727,7 @@ static long socket_address(const struct sockaddr *addr, socklen_t len, int follo char *slash = strrchr(r.host, '/'); if (!slash || slash == r.host) return FP_SLOW; *slash = 0; - *dir = sc(SYS_openat, AT_FDCWD, (long)r.host, O_PATH | O_DIRECTORY | O_CLOEXEC, 0, 0); + *dir = hsc(SYS_openat, r.host, O_PATH | O_DIRECTORY | O_CLOEXEC, 0, 0); if (*dir < 0) return FP_SLOW; hlen = snprintf(out->sun_path, room, "/proc/self/fd/%ld/%s", *dir, slash + 1); if (hlen >= room) { sc(SYS_close, *dir, 0, 0, 0, 0); *dir = -1; return FP_SLOW; } diff --git a/tools/tests/test_battlenet.py b/tools/tests/test_battlenet.py index 5f3d72c1b..ec20e8177 100644 --- a/tools/tests/test_battlenet.py +++ b/tools/tests/test_battlenet.py @@ -198,6 +198,155 @@ class ConfigAndMenuTest(Temp): self.assertEqual(path.stat().st_mtime_ns, mtime) +class DriveTest(Temp): + def setUp(self): + super().setUp() + self.root = str(self.tmp / "Steam") + self.devices = Path(BNET["dosdevices"](self.root)) + self.card = self.tmp / "card" + self.library = self.card / "Games/Battle.net" + self.library.mkdir(parents=True) + self.running = [] + patcher = mock.patch.dict(BNET, {"say": lambda message: None, "wine_processes": lambda pfx, proc="/proc": list(self.running), + "volume_root": lambda path: str(self.card) if BNET["inside"](path, str(self.card)) else "/"}) + patcher.start() + self.addCleanup(patcher.stop) + + def test_a_volume_ends_where_the_device_changes(self): + devices = {"/": 1, "/mnt": 1, "/mnt/card": 7, "/mnt/card/Games": 7, "/mnt/card/Games/Battle.net": 7, "/root": 1, "/root/Games": 1} + with mock.patch.object(os, "stat", lambda path: mock.Mock(st_dev=devices[path])): + volume_root = runpy.run_path(str(BIN / "droiddeck-battlenet"))["volume_root"] + self.assertEqual(volume_root("/mnt/card/Games/Battle.net"), "/mnt/card") + self.assertEqual(volume_root("/mnt/card"), "/mnt/card") + self.assertEqual(volume_root("/root/Games"), "/") + self.assertEqual(volume_root("/"), "/") + + def test_a_library_on_the_root_volume_keeps_its_path_and_gets_no_drive(self): + library = self.tmp / "root/Games/Battle.net" + library.mkdir(parents=True) + self.assertEqual(BNET["install_path"](self.root, str(library) + "/"), "Z:" + str(library)) + self.assertFalse(self.devices.exists()) + + def test_a_library_on_another_volume_gets_a_drive_at_the_volume_root(self): + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "D:\\Games\\Battle.net") + self.assertEqual(os.readlink(self.devices / "d:"), str(self.card)) + self.assertEqual(BNET["install_path"](self.root, str(self.library / "World of Warcraft")), "D:\\Games\\Battle.net\\World of Warcraft") + self.assertEqual(BNET["install_path"](self.root, str(self.card)), "D:\\") + self.assertEqual(sorted(os.listdir(self.devices)), ["d:"]) + + def test_drives_in_use_and_the_ones_proton_owns_are_left_alone(self): + self.devices.mkdir(parents=True) + os.symlink("/somewhere/else", self.devices / "d:") + os.symlink(str(self.card), self.devices / "s:") + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "E:\\Games\\Battle.net") + self.assertEqual(os.readlink(self.devices / "d:"), "/somewhere/else") + for letter in BNET["DRIVES"]: + if not os.path.lexists(self.devices / (letter + ":")): + os.symlink("/somewhere/else", self.devices / (letter + ":")) + os.unlink(self.devices / "e:") + os.symlink("/somewhere/else", self.devices / "e:") + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "Z:" + str(self.library)) + self.assertNotIn("s", BNET["DRIVES"]) + self.assertNotIn("t", BNET["DRIVES"]) + + def test_a_running_prefix_gets_no_new_drive_but_uses_the_ones_it_started_with(self): + self.running = [(5, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])] + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "Z:" + str(self.library)) + self.assertFalse(self.devices.exists()) + self.running = [] + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "D:\\Games\\Battle.net") + self.running = [(5, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])] + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "D:\\Games\\Battle.net") + + def test_a_drive_mapped_inside_the_volume_is_reused(self): + self.devices.mkdir(parents=True) + os.symlink(str(self.card / "Games"), self.devices / "g:") + self.assertEqual(BNET["install_path"](self.root, str(self.library)), "G:\\Battle.net") + self.assertEqual(sorted(os.listdir(self.devices)), ["g:"]) + + def test_the_config_stores_the_drive_path_once(self): + path = self.tmp / ("Steam/steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config" + % BNET["APP_ID"]) + BNET["write_config"](self.root) + self.assertEqual(BNET["write_config"](self.root, str(self.library)), "library") + self.assertEqual(json.loads(path.read_text())["Client"]["Install"]["DefaultInstallPath"], "D:\\Games\\Battle.net") + self.assertIsNone(BNET["write_config"](self.root, str(self.library))) + + def test_registered_paths_resolve_through_their_drive(self): + BNET["install_path"](self.root, str(self.library)) + self.assertEqual(BNET["windows_path"](self.root, "D:/Games/Battle.net/World of Warcraft"), + str(self.library / "World of Warcraft")) + self.assertEqual(BNET["windows_path"](self.root, "Z:\\mnt\\card\\World of Warcraft"), "/mnt/card/World of Warcraft") + self.assertEqual(BNET["windows_path"](self.root, "C:/Program Files (x86)/World of Warcraft"), + os.path.join(BNET["drive_c"](self.root), "Program Files (x86)/World of Warcraft")) + self.assertIsNone(BNET["windows_path"](self.root, "E:/Games/X")) + self.assertIsNone(BNET["windows_path"](self.root, "D:/../etc")) + self.assertEqual(BNET["windows_path"](self.root, "D:\\"), str(self.library.parent.parent)) + + def field(self, number, value): + varint = BNET["encode_varint"] + return varint(number << 3 | 2) + varint(len(value)) + value + + def product(self, uid, folder, filler=b""): + settings = self.field(1, folder.encode()) + self.field(2, b"us") + b"\x18\x02" + filler + return self.field(1, self.field(1, uid.encode()) + self.field(2, uid.encode()) + self.field(3, settings) + + b"\x21" + bytes(8) + b"\x2d" + bytes(4)) + + def database(self, *products): + path = Path(BNET["drive_c"](self.root)) / "ProgramData/Battle.net/Agent/product.db" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"".join(products) + self.field(7, b"tail")) + path.chmod(0o600) + return path + + def test_games_registered_through_the_root_drive_move_to_their_volume_drive(self): + game = self.library / "World of Warcraft" + game.mkdir() + other = self.tmp / "root/Games/Other" + other.mkdir(parents=True) + client = self.product("battle.net", "C:/Program Files (x86)/Battle.net") + local = self.product("other", "Z:" + str(other)) + gone = self.product("gone", "Z:" + str(self.card / "Missing")) + path = self.database(client, self.product("wow", "Z:" + str(game), bytes(200)), local, gone) + self.assertTrue(BNET["move_installs"](self.root)) + self.assertEqual(path.read_bytes(), client + self.product("wow", "D:/Games/Battle.net/World of Warcraft", bytes(200)) + + local + gone + self.field(7, b"tail")) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(os.readlink(self.devices / "d:"), str(self.card)) + self.assertFalse(BNET["move_installs"](self.root)) + + def test_registrations_stay_while_the_prefix_runs_or_the_database_is_unreadable(self): + game = self.library / "World of Warcraft" + game.mkdir() + path = self.database(self.product("wow", "Z:" + str(game))) + before = path.read_bytes() + self.running = [(5, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.exe"])] + self.assertFalse(BNET["move_installs"](self.root)) + self.assertEqual(path.read_bytes(), before) + self.running = [] + for broken in (before[:-3], b"\x0a\xff", b"\x0b\x01"): + path.write_bytes(broken) + self.assertFalse(BNET["move_installs"](self.root)) + self.assertEqual(path.read_bytes(), broken) + os.unlink(path) + self.assertFalse(BNET["move_installs"](self.root)) + + def test_a_database_written_while_it_was_being_read_is_left_as_written(self): + game = self.library / "World of Warcraft" + game.mkdir() + path = self.database(self.product("wow", "Z:" + str(game))) + newer = path.read_bytes() + self.field(7, b"newer") + + def processes(pfx, proc="/proc"): + path.write_bytes(newer) + return [] + + with mock.patch.dict(BNET, {"wine_processes": processes}): + self.assertFalse(BNET["move_installs"](self.root)) + self.assertEqual(path.read_bytes(), newer) + self.assertTrue(BNET["move_installs"](self.root)) + + class GamepadTest(Temp): def registry(self, *entries): rows = ["WINE REGISTRY Version 2"] @@ -772,7 +921,7 @@ class Harness(Temp): "stop": lambda processes, wait=0: self.calls.append(("stop", sorted(pid for pid, _ in processes))), "start": lambda exe, verb=None: self.calls.append(("start", os.path.basename(exe), verb)) or self.Proton(), "spawn": lambda program, verb, args=(): self.calls.append(("spawn", os.path.basename(program), verb, list(args))), - "acquire": lambda folder=None: self.lock, + "acquire": lambda folder=None: self.lock, "volume_root": lambda path: "/", "POLL": 0, "READY_SETTLE": 0, "GAME_GRACE": 0, "CLIENT_WAIT": 0, }) patcher.start() @@ -1170,6 +1319,7 @@ class AgentTest(Temp): script = BNET["AGENT_SCRIPT"] self.assertIn("port = a(0), action = a(1), uid = a(2), product = a(3), dir = a(4)", script) self.assertIn("lang = a(5), country = a(6), region = a(7), out = a(8)", script) + self.assertIn(r'''"game_dir":"' + dir.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '","geo''', script) self.assertIn('"--internalclienttools"', (BIN / "droiddeck-battlenet").read_text()) def test_the_driver_asks_blizzards_chinese_repository_for_chinese_installs(self): @@ -1230,6 +1380,40 @@ class AgentJobTest(Harness): self.assertEqual([kind for kind, _ in self.lines], ["STATE", "PROGRESS", "DONE"]) self.assertEqual(json.loads(self.lines[-1][1]), {"install_dir": "Z:" + folder, "exe": "WarcraftRumble.exe", "version": "16.1.2.119725"}) + def test_an_install_on_another_volume_is_handed_over_by_its_drive_once_the_prefix_is_stopped(self): + self.install() + card = self.tmp / "card" + folder = str(card / "Games/Battle.net/Warcraft Rumble") + self.files = {".json": json.dumps({"progress": 1.0, "download_complete": True, "state": 1004}), + ".game": json.dumps({"installed": True, "install_dir": "D:/Games/Battle.net/Warcraft Rumble", "local_version": "1", + "binaries": {"game": {"relative_path": "WarcraftRumble.exe"}}})} + self.states = [[(7, self.AGENT)]] + events = [] + real_map = BNET["map_drive"] + + def acquire(folder=None): + events.append("lock") + return self.lock + + def stop(pfx): + events.append(("stop", not self.lock.closed)) + self.states = [[]] + return True + + def map_drive(root, volume): + events.append(("map", not self.lock.closed)) + return real_map(root, volume) + + with mock.patch.dict(BNET, {"volume_root": lambda path: str(card), "stop_leftovers": stop, "map_drive": map_drive, + "acquire": acquire}): + code = BNET["agent_job"](self.root, "install", "gryphon", "gryphon", folder, "enUS", "USA", "US", poll=0) + self.assertEqual(code, 0) + self.assertEqual(events, ["lock", ("stop", True), ("map", True)]) + self.assertTrue(self.lock.closed) + cmd = self.started[0] + self.assertEqual(cmd[cmd.index("--") + 6], "D:\\Games\\Battle.net\\Warcraft Rumble") + self.assertEqual(os.readlink(os.path.join(BNET["dosdevices"](self.root), "d:")), str(card)) + def test_an_agent_error_is_reported_with_its_code(self): self.install() self.files = {".json": json.dumps({"error": 2421, "progress": 0.0})} diff --git a/tools/tests/test_client_zram_focus.py b/tools/tests/test_client_zram_focus.py index 5cd7d3cd4..db2d3f80e 100644 --- a/tools/tests/test_client_zram_focus.py +++ b/tools/tests/test_client_zram_focus.py @@ -121,6 +121,7 @@ step() { :; } bl_zram_scan() { :; } bl_memory_read() { :; } bl_client_pressure() { :; } +bl_client_pressure_finish() { :; } bl_session_pressure() { :; } bl_client_can_pageout() { return 0; } bl_client_can_readback() { [ "${readback:-1}" = 1 ]; } diff --git a/tools/tests/test_clock_pause_preload.py b/tools/tests/test_clock_pause_preload.py index 652381112..06ddd65a4 100644 --- a/tools/tests/test_clock_pause_preload.py +++ b/tools/tests/test_clock_pause_preload.py @@ -1,19 +1,34 @@ import os from pathlib import Path +import queue +import re +import shlex import shutil import struct import subprocess import tempfile +import threading import unittest PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +def clock_reset_command(control): + script = (PRELOAD.parent / "overlay/usr/local/bin/droiddeck-session").read_text() + start = script.index('if [ -z "${BL_INSIDE:-}" ]; then\n if [ "${BL_ZRAM:-0}"') + end = script.index("\nfi", start) + len("\nfi") + paths = {"/tmp/.bl-clock": shlex.quote(str(control)), + "/tmp/.bl-zram": shlex.quote(str(control.parent / "zram"))} + pattern = "|".join(re.escape(token) for token in paths) + return 'BL_INSIDE=; BL_ZRAM=0; ' + re.sub(pattern, lambda match: paths[match.group(0)], script[start:end]) + PROGRAM = r""" #define _GNU_SOURCE #include #include #include #include +#include #include #include #include @@ -36,12 +51,26 @@ static int64_t start; static void begin(void) { start = ns(CLOCK_BOOTTIME); } static void end(const char *name) { printf("%s %lld\n", name, (long long)((ns(CLOCK_BOOTTIME) - start) / 1000000LL)); } +static unsigned long long memory_probe(void) { + static volatile unsigned char memory[4 * 1024 * 1024]; + unsigned long long checksum = 0; + for (size_t i = 0; i < sizeof(memory); i += 4096) memory[i] = (unsigned char)(i / 4096 + 1); + for (size_t i = 0; i < sizeof(memory); i += 4096) checksum += memory[i]; + return checksum; +} + int main(void) { const int64_t wait = 60000000LL; printf("gap %lld\n", (long long)((ns(CLOCK_BOOTTIME) - ns(CLOCK_MONOTONIC)) / 1000000LL)); struct timespec raw; syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &raw); printf("rawgap %lld\n", (long long)((ns(CLOCK_BOOTTIME) - ((int64_t)raw.tv_sec * 1000000000LL + raw.tv_nsec)) / 1000000LL)); + printf("memory %llu\n", memory_probe()); + if (getenv("CLOCK_PAUSE_WAIT")) { puts("ready"); fflush(stdout); getchar(); } + if (getenv("CLOCK_PAUSE_WAIT")) { + printf("livegap %lld\n", (long long)((ns(CLOCK_BOOTTIME) - ns(CLOCK_MONOTONIC)) / 1000000LL)); + printf("livememory %llu\n", memory_probe()); + } pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_condattr_t attr; @@ -124,14 +153,124 @@ class ClockPausePreloadTest(unittest.TestCase): self.assertGreaterEqual(paused[name], 50, name) self.assertLess(paused[name], 1000, name) - def test_other_programs_keep_the_real_clock(self): + def test_program_without_preload_keeps_the_real_clock(self): plain = self.run_program(self.other, 0, preload=False) - paused = self.run_program(self.other, SHIFT_MS) + paused = self.run_program(self.other, SHIFT_MS, preload=False) self.assertAlmostEqual(paused["gap"], plain["gap"], delta=50) for name in ("cond_mono", "clockwait", "sem", "nanosleep", "timerfd", "futex"): self.assertGreaterEqual(paused[name], 50, name) self.assertLess(paused[name], 1000, name) + def test_every_session_program_stops_monotonic_clock_and_keeps_memory_access(self): + plain = self.run_program(self.other, 0) + paused = self.run_program(self.other, SHIFT_MS) + self.assertAlmostEqual(paused["gap"] - plain["gap"], SHIFT_MS, delta=50) + self.assertAlmostEqual(paused["rawgap"] - plain["rawgap"], SHIFT_MS, delta=50) + self.assertEqual(plain["memory"], 130560) + self.assertEqual(paused["memory"], 130560) + + def test_running_session_program_observes_pause_offset_update(self): + self.check_running_offset_update(False) + + def test_session_reset_preserves_live_guest_mapping(self): + self.check_running_offset_update(True) + + def check_running_offset_update(self, reset): + self.control.write_bytes(struct.pack(" "$HOME/selected-package"\nexit 1\n') + for tool in bin_dir.iterdir(): + tool.chmod(0o755) + env.pop('DROIDDECK_PACKAGE') + env['DD_LAB_DETACHED'] = '1' + env['PATH'] = str(bin_dir) + os.pathsep + env['PATH'] + result = subprocess.run(['bash', str(SCRIPT), *payload['args']], env=env, + capture_output=True, text=True, timeout=10, cwd=SCRIPT.parents[2]) + self.assertNotEqual(0, result.returncode) + self.assertIn('could not read DroidDeck', result.stderr) + self.assertEqual('com.example.target', (root / 'selected-package').read_text()) + + def test_invalid_packages_are_rejected_before_handoff(self): + with tempfile.TemporaryDirectory() as tmp: + inbox = Path(tmp) / 'agent' + inbox.mkdir() + for package in ('bad;touch injected', '../other', '', 'com.example/other'): + with self.subTest(package=package): + env = {**os.environ, 'HOME': tmp, 'DD_AGENT': '1', 'DD_AGENT_INBOX': str(inbox)} + result = subprocess.run(['bash', str(SCRIPT), '--package=' + package], env=env, + capture_output=True, text=True, timeout=10) + self.assertNotEqual(0, result.returncode) + self.assertIn('invalid Android package', result.stderr) + self.assertFalse((inbox / 'lab-request').exists()) diff --git a/tools/tests/test_descriptor_preload.py b/tools/tests/test_descriptor_preload.py new file mode 100644 index 000000000..f83df2e9b --- /dev/null +++ b/tools/tests/test_descriptor_preload.py @@ -0,0 +1,245 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +FAKE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int answer(int fd) { + char link[32], path[4096]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + long n = syscall(SYS_readlinkat, AT_FDCWD, link, path, sizeof(path) - 1); + path[n > 0 ? n : 0] = '\0'; + if (strstr(path, "/card/")) return ENOTSUP; + if (strstr(path, "/denied/")) return EACCES; + return ENODATA; +} + +ssize_t fgetxattr(int fd, const char *name, void *value, size_t size) { + (void)name; (void)value; (void)size; + errno = answer(fd); + return -1; +} + +int fsetxattr(int fd, const char *name, const void *value, size_t size, int flags) { + (void)name; (void)value; (void)size; (void)flags; + int error = answer(fd); + if (error == ENODATA) return 0; + errno = error; + return -1; +} + +int fremovexattr(int fd, const char *name) { + (void)name; + errno = answer(fd); + return -1; +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static const char *error(void) { + return errno == ENOTSUP ? "unsupported" : errno == ENODATA ? "none" : errno == ERANGE ? "range" : + errno == EACCES ? "denied" : "other"; +} + +int main(int argc, char **argv) { + for (int i = 1; i < argc; i++) { + const char *op = argv[i]; + if (!strcmp(op, "unlink") || !strcmp(op, "rmdir")) { + const char *path = argv[++i]; + printf("%s\n", (op[0] == 'u' ? unlink(path) : rmdir(path)) == 0 ? "ok" : "failed"); + continue; + } + if (!strcmp(op, "remove-path")) { + printf("%s\n", remove(argv[++i]) == 0 ? "ok" : "failed"); + continue; + } + if (!strcmp(op, "rename")) { + const char *from = argv[++i], *to = argv[++i]; + printf("%s\n", rename(from, to) == 0 ? "ok" : "failed"); + continue; + } + int fd = open(argv[++i], O_RDONLY); + char value[64]; + if (!strcmp(op, "set")) { + const char *text = argv[++i]; + errno = 1234; + int result = fsetxattr(fd, "user.wine.sd", text, strlen(text), 0); + printf("%s\n", result == 0 ? errno == 1234 ? "ok" : "errno" : error()); + } else if (!strcmp(op, "get")) { + ssize_t n = fgetxattr(fd, "user.wine.sd", value, sizeof(value)); + if (n < 0) printf("%s\n", error()); + else printf("=%.*s\n", (int)n, value); + } else if (!strcmp(op, "size")) { + ssize_t n = fgetxattr(fd, "user.wine.sd", NULL, 0); + if (n < 0) printf("%s\n", error()); + else printf("%d\n", (int)n); + } else if (!strcmp(op, "short")) { + ssize_t n = fgetxattr(fd, "user.wine.sd", value, 2); + printf("%s\n", n < 0 ? error() : "read"); + } else if (!strcmp(op, "remove")) { + errno = 1234; + int result = fremovexattr(fd, "user.wine.sd"); + printf("%s\n", result == 0 ? errno == 1234 ? "ok" : "errno" : error()); + } else if (!strcmp(op, "other")) { + ssize_t n = fgetxattr(fd, "user.other", value, sizeof(value)); + printf("%s\n", n < 0 ? error() : "read"); + } + close(fd); + } + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc"), "needs a C compiler") +class DescriptorPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build_dir = tempfile.TemporaryDirectory() + build = Path(cls.build_dir.name) + (build / "fake.c").write_text(FAKE) + (build / "program.c").write_text(PROGRAM) + cls.preload = build / "libblsession.so" + cls.fake = build / "fake.so" + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(cls.preload), + *map(str, sorted(PRELOAD.glob("*.c"))), "-ldl"], check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c")], check=True) + cls.wineserver = build / "wineserver" + subprocess.run(["cc", "-O2", "-o", str(cls.wineserver), str(build / "program.c")], check=True) + cls.other = build / "wine64-preloader" + shutil.copy(cls.wineserver, cls.other) + + @classmethod + def tearDownClass(cls): + cls.build_dir.cleanup() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.dir = Path(os.path.realpath(self.temp.name)) + self.prefix = self.dir / "prefix" + self.prefix.mkdir() + self.store = self.prefix / ".droiddeck-descriptors" + for kind in ("card", "disk", "denied"): + (self.dir / kind / "game").mkdir(parents=True) + (self.dir / kind / "game" / "file").write_text("x") + + def tearDown(self): + self.temp.cleanup() + + def path(self, *parts): + return str(self.dir.joinpath(*parts)) + + def run_as(self, program, *args, prefix=True): + env = dict(os.environ, LD_PRELOAD="%s:%s" % (self.preload, self.fake)) + env.pop("WINEPREFIX", None) + if prefix: + env["WINEPREFIX"] = str(self.prefix) + result = subprocess.run([str(program), *args], env=env, capture_output=True, text=True, check=True) + return result.stdout.split() + + def server(self, *args, **kwargs): + return self.run_as(self.wineserver, *args, **kwargs) + + def test_a_descriptor_is_kept_where_the_volume_cannot_store_it(self): + file, folder = self.path("card", "game", "file"), self.path("card", "game") + self.assertEqual(["none", "ok", "ok"], self.server("get", file, "set", file, "one", "set", folder, "two")) + self.assertEqual(["=one", "3", "=two"], self.server("get", file, "size", file, "get", folder)) + + def test_a_descriptor_is_replaced_and_removed(self): + file = self.path("card", "game", "file") + self.assertEqual(["ok", "ok", "=longer"], self.server("set", file, "one", "set", file, "longer", "get", file)) + self.assertEqual(["range", "ok", "none", "none"], self.server("short", file, "remove", file, "get", file, "remove", file)) + + def test_a_volume_that_stores_descriptors_is_left_alone(self): + file = self.path("disk", "game", "file") + self.assertEqual(["ok", "none", "none"], self.server("set", file, "one", "get", file, "remove", file)) + self.assertFalse(self.store.exists()) + + def test_other_failures_keep_their_error(self): + file = self.path("denied", "game", "file") + self.assertEqual(["denied", "denied", "denied"], self.server("set", file, "one", "get", file, "remove", file)) + self.assertFalse(self.store.exists()) + + def test_other_programs_and_other_attributes_are_unchanged(self): + file = self.path("card", "game", "file") + self.assertEqual(["unsupported", "unsupported", "unsupported"], + self.run_as(self.other, "set", file, "one", "get", file, "remove", file)) + self.assertEqual(["ok", "unsupported"], self.server("set", file, "one", "other", file)) + self.assertEqual(["unsupported"], self.run_as(self.other, "get", file)) + + def test_nothing_is_kept_without_a_prefix(self): + file = self.path("card", "game", "file") + self.assertEqual(["unsupported", "unsupported", "unsupported"], + self.server("set", file, "one", "get", file, "remove", file, prefix=False)) + + def test_a_deleted_file_loses_its_descriptor(self): + file, folder = self.path("card", "game", "file"), self.path("card", "game") + self.assertEqual(["ok", "ok", "ok"], self.server("set", file, "one", "set", folder, "two", "unlink", file)) + Path(file).write_text("x") + self.assertEqual(["none", "ok", "ok"], self.server("get", file, "unlink", file, "rmdir", folder)) + Path(folder).mkdir() + self.assertEqual(["none"], self.server("get", folder)) + + def test_a_removed_file_or_folder_loses_its_descriptor(self): + file, folder = self.path("card", "game", "file"), self.path("card", "game") + self.assertEqual(["ok", "ok", "ok", "ok"], + self.server("set", file, "one", "set", folder, "two", "remove-path", file, "remove-path", folder)) + Path(folder).mkdir() + Path(file).write_text("x") + self.assertEqual(["none", "none"], self.server("get", folder, "get", file)) + + def test_a_file_deleted_through_a_link_loses_its_descriptor(self): + file = self.path("card", "game", "file") + os.symlink(self.path("card"), self.path("drive")) + self.assertEqual(["ok", "ok"], self.server("set", file, "one", "unlink", self.path("drive", "game", "file"))) + Path(file).write_text("x") + self.assertEqual(["none"], self.server("get", file)) + + def test_a_descriptor_follows_a_rename(self): + file, moved = self.path("card", "game", "file"), self.path("card", "game", "moved") + self.assertEqual(["ok", "ok", "=one"], self.server("set", file, "one", "rename", file, moved, "get", moved)) + Path(file).write_text("x") + self.assertEqual(["none", "ok", "none"], self.server("get", file, "rename", file, moved, "get", moved)) + + def test_descriptors_follow_a_renamed_folder(self): + file, folder, moved = self.path("card", "game", "file"), self.path("card", "game"), self.path("card", "moved") + self.assertEqual(["ok", "ok", "ok"], self.server("set", file, "one", "set", folder, "two", "rename", folder, moved)) + self.assertEqual(["=two", "=one"], self.server("get", moved, "get", self.path("card", "moved", "file"))) + Path(folder).mkdir() + (Path(folder) / "file").write_text("x") + self.assertEqual(["none", "none"], self.server("get", folder, "get", file)) + + def test_deletes_outside_the_store_cost_nothing_before_it_exists(self): + file = self.path("disk", "game", "file") + self.assertEqual(["ok"], self.server("unlink", file)) + self.assertFalse(self.store.exists()) + + def test_a_name_too_long_to_keep_reports_no_support(self): + long_file = self.path("card", "game", "n" * 254) + Path(long_file).write_text("x") + self.assertEqual(["unsupported", "unsupported"], self.server("set", long_file, "one", "get", long_file)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_desktop_open.py b/tools/tests/test_desktop_open.py index c32baf71a..a7840cfa5 100644 --- a/tools/tests/test_desktop_open.py +++ b/tools/tests/test_desktop_open.py @@ -136,6 +136,30 @@ class CommandTest(Temp): self.assertEqual(OPEN["command"](script, [], "x86 script")[0], [OPEN["FEX"], "run", "--mode", "on", "--for", str(self.tmp), "--", "/bin/sh", script]) + def test_a_store_games_exe_runs_through_its_launcher(self): + exe, launcher = str(self.tmp / "game.exe"), str(self.tmp / "launch.bat") + with mock.patch.dict(OPEN, {"store_launcher": lambda path: launcher if path == exe else None, + "exe_prefix": lambda path: ["--prefix", "4242", "--compatdata", "/c/4242"]}): + keep = ["--prefix", "4242", "--compatdata", "/c/4242"] + self.assertEqual(OPEN["command"](exe, [], "windows")[0], [OPEN["PROTON_RUN"]] + keep + [launcher]) + self.assertEqual(OPEN["command"](exe, [], "windows", True)[0], + [OPEN["PROTON_RUN"], "--window"] + keep + [launcher]) + self.assertEqual(OPEN["command"](exe, ["-x"], "windows")[0], [OPEN["PROTON_RUN"], exe, "-x"]) + other = str(self.tmp / "other.exe") + self.assertEqual(OPEN["command"](other, [], "windows")[0], [OPEN["PROTON_RUN"], other]) + + def test_a_store_games_launcher_keeps_the_exes_prefix(self): + exe = self.tmp / "game.exe" + exe.write_text("x") + root = str(self.tmp / "steam") + with mock.patch.dict(os.environ, {"STEAM_COMPAT_CLIENT_INSTALL_PATH": root}), \ + mock.patch.dict(OPEN, {"PROTON_RUN": str(BIN / "droiddeck-proton-run"), + "store_launcher": lambda path: str(self.tmp / "launch.bat")}): + command = OPEN["command"](str(exe), [], "windows")[0] + located = RUN["locate"](str(exe), root) + self.assertEqual(command[1:5], ["--prefix", located.appid, "--compatdata", located.compatdata]) + self.assertEqual(str(RUN["own_id"](str(exe))), str(RUN["locate"](str(exe), root).appid)) + def test_a_native_program_on_noexec_storage_runs_from_a_copy(self): program = self.write("storage/tool", elf(183)) x86 = self.write("storage/game.x86_64", elf(62)) @@ -737,6 +761,20 @@ class ProtonRunTest(Temp): self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(signed)) self.assertFalse((self.root / "steamapps/compatdata/2733369024").exists()) + def test_a_launcher_run_keeps_the_resolved_compatdata_folder(self): + bat = self.write("Games/Epic/Game/.droiddeck-launch.bat", b"@echo off") + signed = self.root / "steamapps/compatdata/-1561598272" + signed.mkdir(parents=True) + other = self.tmp / "Library2/steamapps/compatdata/555" + other.mkdir(parents=True) + for compatdata, appid in ((signed, "2733369024"), (other, "555")): + with self.subTest(compatdata=str(compatdata)): + code, calls, dirs = self.run_main(["--prefix", appid, "--compatdata", str(compatdata), str(bat)]) + self.assertIsNone(code) + self.assertEqual(calls[0][2]["STEAM_COMPAT_DATA_PATH"], str(compatdata)) + code, calls, dirs = self.run_main(["--prefix", "555", str(bat)]) + self.assertEqual(calls[0][2]["STEAM_COMPAT_DATA_PATH"], str(self.root / "steamapps/compatdata/555")) + def test_prefix_folder_prefers_initialized_unsigned_over_empty_signed(self): signed = self.root / "steamapps/compatdata/-1561598272" signed.mkdir(parents=True) diff --git a/tools/tests/test_fastpath_fuse_dirs.py b/tools/tests/test_fastpath_fuse_dirs.py new file mode 100644 index 000000000..b8b45f851 --- /dev/null +++ b/tools/tests/test_fastpath_fuse_dirs.py @@ -0,0 +1,314 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +USERNS = shutil.which("unshare") is not None and subprocess.run(["unshare", "-Urm", "true"], capture_output=True).returncode == 0 + +FASTPATH = Path(__file__).resolve().parents[1] / "proot/fastpath/fastpath.c" + +PROGRAM = r""" +#include "FASTPATH" +#include + +static const char *race_from, *race_to; +static dev_t race_dev; +static ino_t race_ino; + +static long trapped_nr = -1, unsupported_nr = -1; + +static long host_sys(long nr, long a, long b, long c, long d, long e, long f) { + if (nr == trapped_nr || (nr == unsupported_nr && a != -1)) return -ENOSYS; + long r = syscall(nr, a, b, c, d, e, f); + struct stat fs; + if (r >= 0 && nr == SYS_fcntl && b == F_DUPFD_CLOEXEC && c == DIR_FD_FLOOR && race_from + && syscall(SYS_fstat, a, &fs) == 0 && fs.st_dev == race_dev && fs.st_ino == race_ino) { + syscall(SYS_renameat, AT_FDCWD, race_from, AT_FDCWD, race_to); + race_from = NULL; + } + return r < 0 ? -errno : r; +} + +static void show(long r) { + if (r == FP_SLOW) puts("SLOW"); + else if (r < 0) printf("E%ld\n", -r); + else puts("ok"); +} + +static int high_fds(void) { + int n = 0; + for (int fd = DIR_FD_FLOOR; fd < DIR_FD_FLOOR + 4 * DIRS; fd++) + if (fcntl(fd, F_GETFD) >= 0) n++; + return n; +} + +int main(int argc, char **argv) { + static char hosts[MAX_BINDS][PATH_MAX], guests[MAX_BINDS][512]; + static int taken[DIRS]; + int ntaken = 0; + fp_sys = host_sys; + snprintf(root, sizeof root, "%s", argv[1]); + rootlen = strlen(root); + int i = 2; + for (; i + 1 < argc && strcmp(argv[i], "--"); i += 2) { + struct bind *bd = &binds[nbinds]; + bd->host = hosts[nbinds]; + bd->guest = guests[nbinds]; + snprintf(bd->host, PATH_MAX, "%s", argv[i]); + snprintf(bd->guest, sizeof guests[0], "%s", argv[i + 1]); + bd->hlen = strlen(bd->host); + bd->glen = strlen(bd->guest); + nbinds++; + } + pthread_once(&init_once, fp_init); + fp_on = 1; + for (i++; i < argc; i++) { + const char *op = argv[i]; + if (!strcmp(op, "fuse")) { + for (int b = 0; b < nbinds; b++) binds[b].fuse = 1; + } else if (!strcmp(op, "touch")) { + long fd = fp_open(AT_FDCWD, argv[++i], O_CREAT | O_WRONLY | O_CLOEXEC, 0644); + if (fd >= 0) close((int)fd); + show(fd); + } else if (!strcmp(op, "stat")) { + struct stat st; + show(fp_stat(AT_FDCWD, argv[++i], AT_SYMLINK_NOFOLLOW, &st)); + } else if (!strcmp(op, "symlink")) { + long r = fp_symlink("target", AT_FDCWD, argv[++i]); + if (refused()) puts("REFUSED"); + else show(r); + } else if (!strcmp(op, "trap-symlink")) { + trapped_nr = SYS_symlinkat; + } else if (!strcmp(op, "no-symlinks")) { + unsupported_nr = SYS_symlinkat; + } else if (!strcmp(op, "on")) { + printf("%d\n", fp_ready()); + } else if (!strcmp(op, "no-folders")) { + unsupported_nr = SYS_mkdirat; + } else if (!strcmp(op, "busy-cache")) { + pthread_mutex_lock(&dir_lock); + } else if (!strcmp(op, "mkdir")) { + long r = fp_mkdir(AT_FDCWD, argv[++i], 0755); + if (refused()) puts("REFUSED"); + else show(r); + } else if (!strcmp(op, "mv")) { + show(fp_rename(AT_FDCWD, argv[i + 1], AT_FDCWD, argv[i + 2], 0)); + i += 2; + } else if (!strcmp(op, "rm")) { + show(fp_unlink(AT_FDCWD, argv[++i], 0)); + } else if (!strcmp(op, "host-mv")) { + show(host_sys(SYS_renameat, AT_FDCWD, (long)argv[i + 1], AT_FDCWD, (long)argv[i + 2], 0, 0)); + i += 2; + } else if (!strcmp(op, "race-mv")) { + struct stat rs; + race_from = argv[i + 1]; + race_to = argv[i + 2]; + i += 2; + show(host_sys(SYS_newfstatat, AT_FDCWD, (long)race_from, (long)&rs, 0, 0, 0)); + race_dev = rs.st_dev; + race_ino = rs.st_ino; + } else if (!strcmp(op, "host-rmdir")) { + show(host_sys(SYS_unlinkat, AT_FDCWD, (long)argv[++i], AT_REMOVEDIR, 0, 0, 0)); + } else if (!strcmp(op, "host-mkdir")) { + show(host_sys(SYS_mkdirat, AT_FDCWD, (long)argv[++i], 0755, 0, 0, 0)); + } else if (!strcmp(op, "clobber")) { + long null = host_sys(SYS_openat, AT_FDCWD, (long)"/dev/null", O_RDONLY, 0, 0, 0); + for (int fd = DIR_FD_FLOOR; fd < DIR_FD_FLOOR + DIRS; fd++) host_sys(SYS_dup3, null, fd, 0, 0, 0, 0); + host_sys(SYS_close, null, 0, 0, 0, 0, 0); + puts("ok"); + } else if (!strcmp(op, "first-fd")) { + char link[40], out[PATH_MAX]; + snprintf(link, sizeof link, "/proc/self/fd/%d", DIR_FD_FLOOR); + long n = host_sys(SYS_readlinkat, AT_FDCWD, (long)link, (long)out, sizeof out - 1, 0, 0); + out[n > 0 ? n : 0] = 0; + puts(out); + } else if (!strcmp(op, "fds")) { + printf("%d\n", high_fds()); + } else if (!strcmp(op, "ttl")) { + ttl_ns = atoll(argv[++i]) * 1000000LL; + } else if (!strcmp(op, "sleep")) { + usleep(atoi(argv[++i]) * 1000); + } else if (!strcmp(op, "chmod")) { + show(fp_chmod(AT_FDCWD, argv[i + 1], strtol(argv[i + 2], NULL, 8), 0)); + i += 2; + } else if (!strcmp(op, "host-mount")) { + show(mount("tmpfs", argv[++i], "tmpfs", 0, NULL) == 0 ? 0 : -errno); + } else if (!strcmp(op, "take-over")) { + for (int fd = DIR_FD_FLOOR; fd < DIR_FD_FLOOR + DIRS; fd++) { + char link[40], out[PATH_MAX]; + snprintf(link, sizeof link, "/proc/self/fd/%d", fd); + long n = host_sys(SYS_readlinkat, AT_FDCWD, (long)link, (long)out, sizeof out - 1, 0, 0); + if (n <= 0) continue; + out[n] = 0; + long mine = host_sys(SYS_openat, AT_FDCWD, (long)out, O_RDONLY | O_DIRECTORY, 0, 0, 0); + host_sys(SYS_dup3, mine, fd, 0, 0, 0, 0); + host_sys(SYS_close, mine, 0, 0, 0, 0, 0); + taken[ntaken++] = fd; + } + puts("ok"); + } else if (!strcmp(op, "taken")) { + int open_fds = 0; + for (int t = 0; t < ntaken; t++) + if (lseek(taken[t], 0, SEEK_CUR) == 0) open_fds++; + printf("%d/%d\n", open_fds, ntaken); + } + } + return 0; +} +""" + + +class FastPathFuseDirsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if shutil.which("gcc") is None: + raise unittest.SkipTest("gcc is not available") + cls.build = tempfile.TemporaryDirectory() + source = Path(cls.build.name) / "fuse_dirs.c" + source.write_text(PROGRAM.replace("FASTPATH", str(FASTPATH))) + cls.program = Path(cls.build.name) / "fuse_dirs" + subprocess.run(["gcc", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-format-truncation", "-pthread", "-o", str(cls.program), str(source), "-ldl"], check=True) + + @classmethod + def tearDownClass(cls): + cls.build.cleanup() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + base = Path(self.temp.name) + self.root = base / "root" + (self.root / "mnt").mkdir(parents=True) + self.card = base / "card" + (self.card / "Games/steamapps/common/Game/Data").mkdir(parents=True) + + def ask(self, *ops, wrap=()): + argv = [*wrap, str(self.program), str(self.root), str(self.card), "/mnt/card", "--", *ops] + return subprocess.run(argv, capture_output=True, text=True, timeout=20, check=True).stdout.split() + + def test_files_deep_in_a_fuse_library_are_created_renamed_and_removed(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("fuse", "touch", f"{data}/a.tmp", "mv", f"{data}/a.tmp", f"{data}/a.idx", + "stat", f"{data}/a.idx", "stat", f"{data}/a.tmp", "rm", f"{data}/a.idx", + "stat", f"{data}/a.idx", "fds") + self.assertEqual(["ok", "ok", "ok", "E2", "ok", "E2"], out[:-1]) + self.assertGreater(int(out[-1]), 0) + self.assertEqual([], list((self.card / "Games/steamapps/common/Game/Data").iterdir())) + + def test_a_folder_renamed_behind_the_cache_is_not_reused(self): + game = self.card / "Games/steamapps/common/Game" + (game / "Data/keep").write_text("x") + out = self.ask("fuse", "stat", "/mnt/card/Games/steamapps/common/Game/Data/keep", + "host-mv", str(game), str(game.parent / "Moved"), + "stat", "/mnt/card/Games/steamapps/common/Game/Data/keep", + "stat", "/mnt/card/Games/steamapps/common/Moved/Data/keep") + self.assertEqual(["ok", "ok", "E2", "ok"], out) + + def test_a_folder_renamed_while_it_is_opened_is_not_used_under_its_old_name(self): + game = self.card / "Games/steamapps/common/Game" + (game / "Data/keep").write_text("x") + out = self.ask("fuse", "race-mv", str(game / "Data"), str(game / "Moved"), + "stat", "/mnt/card/Games/steamapps/common/Game/Data/keep", + "touch", "/mnt/card/Games/steamapps/common/Game/Data/new", "fds") + self.assertEqual(["ok", "E2", "E2"], out[:3]) + self.assertEqual(["keep"], [p.name for p in (game / "Moved").iterdir()]) + self.assertLessEqual(int(out[-1]), 64) + + def test_a_folder_deleted_and_made_again_is_looked_up_fresh(self): + data = self.card / "Games/steamapps/common/Game/Data" + out = self.ask("fuse", "touch", "/mnt/card/Games/steamapps/common/Game/Data/old", + "rm", "/mnt/card/Games/steamapps/common/Game/Data/old", + "host-rmdir", str(data), "host-mkdir", str(data), + "touch", "/mnt/card/Games/steamapps/common/Game/Data/new") + self.assertEqual(["ok"] * 5, out) + self.assertEqual(["new"], [p.name for p in data.iterdir()]) + + def test_folders_deleted_and_made_again_many_times_keep_descriptors_bounded(self): + data = self.card / "Games/steamapps/common/Game/Data" + ops = ["fuse"] + for _ in range(200): + ops += ["touch", "/mnt/card/Games/steamapps/common/Game/Data/x", + "rm", "/mnt/card/Games/steamapps/common/Game/Data/x", + "host-rmdir", str(data), "host-mkdir", str(data)] + out = self.ask(*ops, "fds") + self.assertEqual(["ok"] * 800, out[:-1]) + self.assertLessEqual(int(out[-1]), 64) + + def test_descriptors_the_program_takes_over_are_left_alone(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("fuse", "touch", f"{data}/a", "clobber", "touch", f"{data}/b", + "stat", f"{data}/a", "first-fd") + self.assertEqual(["ok", "ok", "ok", "ok", "/dev/null"], out) + self.assertEqual({"a", "b"}, {p.name for p in (self.card / "Games/steamapps/common/Game/Data").iterdir()}) + + def test_a_folder_the_program_opened_itself_stays_open_when_the_cache_moves_on(self): + data = self.card / "Games/steamapps/common/Game/Data" + ops = ["fuse", "touch", "/mnt/card/Games/steamapps/common/Game/Data/a", "take-over"] + for n in range(200): + (data / f"d{n}").mkdir() + ops += ["touch", f"/mnt/card/Games/steamapps/common/Game/Data/d{n}/x"] + out = self.ask(*ops, "taken") + self.assertEqual(["ok"] * 202, out[:-1]) + opened, total = map(int, out[-1].split("/")) + self.assertGreater(total, 0) + self.assertEqual(total, opened) + self.assertEqual(200, len(list(data.glob("d*/x")))) + + @unittest.skipIf(os.geteuid() == 0, "root passes folder permissions") + def test_a_parent_folder_closed_by_the_program_stops_cached_access_at_once(self): + game = self.card / "Games/steamapps/common/Game" + (game / "Data/keep").write_text("x") + self.addCleanup(os.chmod, game, 0o755) + out = self.ask("fuse", "stat", "/mnt/card/Games/steamapps/common/Game/Data/keep", + "chmod", "/mnt/card/Games/steamapps/common/Game", "000", + "stat", "/mnt/card/Games/steamapps/common/Game/Data/keep", + "touch", "/mnt/card/Games/steamapps/common/Game/Data/new") + self.assertEqual(["ok", "ok"], out[:2]) + self.assertNotIn("ok", out[2:]) + os.chmod(game, 0o755) + self.assertEqual(["keep"], [p.name for p in (game / "Data").iterdir()]) + + @unittest.skipUnless(USERNS, "mount namespaces are not available") + def test_a_mount_placed_over_a_cached_folder_receives_later_writes(self): + data = self.card / "Games/steamapps/common/Game/Data" + out = self.ask("fuse", "ttl", "1", "touch", "/mnt/card/Games/steamapps/common/Game/Data/under", + "host-mount", str(data), "sleep", "20", + "touch", "/mnt/card/Games/steamapps/common/Game/Data/over", + "stat", "/mnt/card/Games/steamapps/common/Game/Data/under", wrap=("unshare", "-Urm")) + self.assertEqual(["ok", "ok", "ok", "E2"], out) + self.assertEqual(["under"], [p.name for p in data.iterdir()]) + + def test_a_call_the_filesystem_does_not_implement_leaves_the_fast_path_on(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("fuse", "no-symlinks", "touch", f"{data}/a", "symlink", f"{data}/link", "on", + "stat", f"{data}/a", "symlink", f"{data}/link", "on", "touch", f"{data}/b") + self.assertEqual(["ok", "REFUSED", "1", "ok", "REFUSED", "1", "ok"], out) + self.assertEqual(["a", "b"], sorted(p.name for p in (self.card / "Games/steamapps/common/Game/Data").iterdir())) + + def test_repeated_unsupported_calls_close_the_folders_they_opened(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + ops = [op for n in range(40) for op in ("mkdir", f"{data}/new{n}")] + out = self.ask("fuse", "no-folders", "busy-cache", *ops, "on", "fds") + self.assertEqual(["REFUSED"] * 40 + ["1", "0"], out) + + def test_a_trapped_call_still_stops_the_fast_path_for_the_process(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("fuse", "trap-symlink", "symlink", f"{data}/link", "on") + self.assertEqual(["REFUSED", "0"], out) + + def test_a_symlink_is_made_where_the_filesystem_allows_it(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("fuse", "symlink", f"{data}/link", "on") + self.assertEqual(["ok", "1"], out) + self.assertEqual("target", os.readlink(self.card / "Games/steamapps/common/Game/Data/link")) + + def test_other_filesystems_keep_absolute_paths_and_open_no_folders(self): + data = "/mnt/card/Games/steamapps/common/Game/Data" + out = self.ask("touch", f"{data}/a", "mv", f"{data}/a", f"{data}/b", "rm", f"{data}/b", "fds") + self.assertEqual(["ok", "ok", "ok", "0"], out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index d6cb49582..5f7b4af08 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -993,8 +993,10 @@ class GameEnvironmentTest(unittest.TestCase): def test_the_vram_cap_comes_before_the_games_own_dxvk_settings(self): cap = MODULE["vram_cap"] self.assertEqual(cap({"BL_VRAM_MB": "4096"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4096") + self.assertEqual(cap({"BL_VRAM_MB": "8192"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 8192") + self.assertEqual(cap({"BL_VRAM_MB": "4095"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4095; d3d9.maxAvailableMemory = 4095") self.assertEqual(cap({"BL_VRAM_MB": "2048", "DXVK_CONFIG": "dxvk.maxFrameRate = 60;"})["DXVK_CONFIG"], - "dxgi.maxDeviceMemory = 2048; dxvk.maxFrameRate = 60") + "dxgi.maxDeviceMemory = 2048; d3d9.maxAvailableMemory = 2048; dxvk.maxFrameRate = 60") for off in ({}, {"BL_VRAM_MB": "0"}, {"BL_VRAM_MB": "lots"}, {"BL_VRAM_MB": "²"}, {"BL_VRAM_MB": "-1"}): self.assertNotIn("DXVK_CONFIG", cap(off)) self.assertNotIn("DROIDDECK_VRAM_CAP", cap(off)) @@ -1520,6 +1522,19 @@ class GameEnvironmentTest(unittest.TestCase): config = {"version": 1, "shared": {}, "games": {"42": values}} self.assertEqual(MODULE["apply_config"]({}, config, "42", env), values, name) + def test_ff7_rebirth_runs_at_feature_level_12_2_unless_the_game_overrides_it(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "FINAL FANTASY VII REBIRTH" + game = root / "End/Binaries/Win64/ff7rebirth_.exe" + game.parent.mkdir(parents=True) + for exe in (root / "ff7rebirth.exe", game): + exe.touch() + env, extra, notes = MODULE["engine_fixes"](str(exe), []) + self.assertEqual(env, {"VKD3D_FEATURE_LEVEL": "12_2"}, exe.name) + self.assertTrue(notes) + config = {"version": 1, "shared": {}, "games": {"42": {"VKD3D_FEATURE_LEVEL": "12_1"}}} + self.assertEqual(MODULE["apply_config"]({}, config, "42", env), {"VKD3D_FEATURE_LEVEL": "12_1"}) + def test_unity_players_keep_tso_over_the_preset(self): with tempfile.TemporaryDirectory() as tmp: def game(*names): diff --git a/tools/tests/test_memory_pressure.py b/tools/tests/test_memory_pressure.py index 13f9e2f8d..01c32c3f8 100644 --- a/tools/tests/test_memory_pressure.py +++ b/tools/tests/test_memory_pressure.py @@ -2,7 +2,7 @@ from pathlib import Path import tempfile import unittest -from tools.tests.test_client_zram_focus import function_source, run_bash +from tools.tests.test_client_zram_focus import SCRIPT, function_source, run_bash class MemoryPressureTest(unittest.TestCase): @@ -20,8 +20,8 @@ class MemoryPressureTest(unittest.TestCase): def run_policy(self, body): functions = '\n'.join(function_source(name) for name in ( - 'bl_memory_read', 'bl_memory_tight', 'bl_client_can_pageout', - 'bl_client_can_readback', 'bl_client_pressure')) + 'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', 'bl_client_can_pageout', + 'bl_client_can_readback', 'bl_client_pressure', 'bl_client_pressure_result', 'bl_zram_has_room')) functions = functions.replace('/proc/meminfo', str(self.root / 'meminfo')) functions = functions.replace('/proc/pressure/memory', str(self.root / 'pressure')) harness = ''' @@ -29,7 +29,7 @@ set -u _bl_pids=(123) _bl_game=1 _bl_packed=-1 _bl_pressure_at=-1 _bl_pressure_backoff=30 rss=$((384 * 1024)) client_swap=$((512 * 1024)) reclaimed=$((128 * 1024)) step() { :; } -bl_zram_usage() { _bl_rss=$rss _bl_swap=$client_swap; } +bl_zram_usage() { _bl_rss=$rss _bl_anon=${anon:-$rss} _bl_swap=$client_swap; } bl_zram_send() { echo "send:$1:$3"; rss=$((rss - reclaimed)); _bl_pageout_complete=${pageout_complete:-1}; } ''' + functions + '\nbl_memory_read\n' + body result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW')) @@ -80,6 +80,13 @@ if bl_client_can_readback; then echo yes; else echo no; fi self.assertEqual(['no'], self.run_policy(''' bl_client_pressure if bl_client_can_pageout; then echo yes; else echo no; fi +''')) + + def test_client_swap_budget_excludes_file_backed_memory(self): + self.memory(swap_free=1536) + self.assertEqual(['send:1:client'], self.run_policy(''' +rss=$((2048 * 1024)) anon=$((256 * 1024)) +bl_client_pressure ''')) def test_without_swap_automatic_pageout_is_disabled(self): @@ -152,20 +159,20 @@ class SessionPressureTest(unittest.TestCase): def run_policy(self, body): functions = '\n'.join(function_source(name) for name in ( - 'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', 'bl_low_ram', - 'bl_session_pressure', 'bl_watch_nap', 'bl_zram_pick', 'bl_tier_note', 'bl_tier_readback')) + 'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', + 'bl_session_pressure', 'bl_zram_has_room', 'bl_watch_nap', 'bl_zram_pick', 'bl_tier_note', 'bl_tier_readback')) functions = functions.replace('/proc/meminfo', str(self.root / 'meminfo')) functions = functions.replace('/proc/pressure/memory', str(self.root / 'pressure')) harness = ''' set -u _bl_game=1 _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_pids=(999) _bl_pressure_at=-1 _bl_packed=-1 -rss=$((1536 * 1024)) swap=0 pushed=$((1024 * 1024)) alive=999 started=100 complete=1 +rss=$((1536 * 1024)) swap=0 pushed=$((1024 * 1024)) alive="999 321 654" started=100 complete=1 step() { :; } bl_proc_start() { _bl_start=; case " $alive " in *" $1 "*) _bl_start=$started; return 0 ;; esac; return 1; } nap() { echo "nap:$1"; } bl_session_others() { _bl_session=(321 654); } -bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; } +bl_zram_usage() { _bl_rss=$rss _bl_anon=${anon:-$rss} _bl_swap=$swap; } bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; } ''' + functions + '\nbl_memory_read\n' + body result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW')) @@ -174,19 +181,27 @@ bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed return result.stdout.splitlines() def test_low_headroom_compresses_the_game_session_at_most_every_30_seconds(self): - self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' + self.assertEqual(['send:1:session', 'send:1:session'], self.run_policy(''' SECONDS=0; bl_session_pressure SECONDS=10; bl_session_pressure SECONDS=29; bl_session_pressure SECONDS=30; bl_session_pressure ''')) - def test_twelve_gigabyte_devices_are_included_and_larger_ones_are_not(self): - for total, expected in ((11264, ['send:1:game']), (12288, ['send:1:game']), (15360, [])): + def test_pressure_policy_is_independent_of_ram_size(self): + for total, expected in ((11264, ['send:1:session']), (12288, ['send:1:session']), (16384, ['send:1:session'])): with self.subTest(total=total): self.memory(total=total) self.assertEqual(expected, self.run_policy('bl_session_pressure')) + def test_large_device_with_real_headroom_does_not_reclaim(self): + self.memory(total=16384, available=5120, psi='12.34') + self.assertEqual([], self.run_policy('bl_session_pressure')) + + def test_large_device_with_psi_and_moderate_headroom_reclaims(self): + self.memory(total=16384, available=3072, psi='1.23') + self.assertEqual(['send:1:session'], self.run_policy('bl_session_pressure')) + def test_no_game_does_not_compress_the_session(self): self.assertEqual([], self.run_policy('_bl_game=0; bl_session_pressure')) @@ -196,15 +211,26 @@ SECONDS=30; bl_session_pressure def test_stalls_with_moderate_headroom_compress_before_the_hard_limit(self): self.memory(available=2048, psi='1.50') - self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure')) + self.assertEqual(['send:1:session'], self.run_policy('bl_session_pressure')) def test_moderate_headroom_without_stalls_waits(self): self.memory(available=2048) self.assertEqual([], self.run_policy('bl_session_pressure')) + def test_same_second_memory_read_preserves_the_pressure_baseline(self): + self.memory(available=2048) + meminfo = self.root / 'meminfo' + self.assertEqual(['1048576:524288:2097152'], self.run_policy(f''' +SECONDS=100 +_bl_sample_at=100 _bl_sample_available=2097152 _bl_growth=0 _bl_fall=524288 +sed -i 's/MemAvailable: 2097152 kB/MemAvailable: 1048576 kB/' {meminfo} +bl_memory_read +echo "$_bl_available:$_bl_fall:$_bl_sample_available" +''')) + def test_game_reading_back_most_of_a_pageout_skips_and_doubles_the_wait(self): - self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game', 'backoff:60', - 'send:1:game', 'backoff:30'], self.run_policy(''' + self.assertEqual(['send:1:session', 'backoff:60', 'send:1:session', 'backoff:60', + 'send:1:session', 'backoff:30'], self.run_policy(''' pushed=$((512 * 1024)) SECONDS=0; bl_session_pressure swap=$((swap / 4)) @@ -226,7 +252,7 @@ echo "backoff:$_bl_tier_backoff" def test_nearly_out_of_memory_ignores_the_backoff_but_keeps_30_seconds(self): self.memory(available=512) - self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' + self.assertEqual(['send:1:session', 'send:1:session'], self.run_policy(''' _bl_tier_backoff=300 SECONDS=0; bl_session_pressure SECONDS=20; bl_session_pressure @@ -235,7 +261,7 @@ SECONDS=30; bl_session_pressure def test_nearly_out_of_memory_still_backs_off_when_the_game_reads_its_pageout_back(self): self.memory(available=512) - self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game'], self.run_policy(''' + self.assertEqual(['send:1:session', 'backoff:60', 'send:1:session'], self.run_policy(''' SECONDS=0; bl_session_pressure swap=0 SECONDS=30; bl_session_pressure @@ -247,7 +273,7 @@ SECONDS=90; bl_session_pressure def test_nearly_out_of_memory_retries_a_skipped_pageout_after_30_seconds(self): self.memory(available=512) - self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' + self.assertEqual(['send:1:session', 'send:1:session'], self.run_policy(''' SECONDS=0; bl_session_pressure swap=0 SECONDS=30; bl_session_pressure @@ -263,6 +289,24 @@ SECONDS=360; bl_session_pressure self.memory(swap_free=1536, swap_total=2048) self.assertEqual([], self.run_policy('bl_session_pressure')) + def test_file_backed_resident_memory_does_not_consume_the_swap_budget(self): + self.memory(swap_free=3088, swap_total=6144) + self.assertEqual(['send:1:session'], self.run_policy(''' +rss=$((4096 * 1024)) anon=$((1024 * 1024)) +bl_session_pressure +''')) + + def test_anonymous_memory_and_pending_client_still_need_the_full_swap_reserve(self): + self.memory(swap_free=3088, swap_total=6144) + self.assertEqual([], self.run_policy(''' +rss=$((4096 * 1024)) anon=$((2048 * 1024)) +bl_session_pressure +''')) + self.assertEqual([], self.run_policy(''' +rss=$((4096 * 1024)) anon=$((1536 * 1024)) _bl_client_reserve=$((512 * 1024)) +bl_session_pressure +''')) + def test_small_session_is_left_alone(self): self.assertEqual([], self.run_policy('rss=$((128 * 1024)); bl_session_pressure')) @@ -275,10 +319,10 @@ SECONDS=360; bl_session_pressure self.assertEqual(['nap:5'], self.run_policy('bl_watch_nap')) self.assertEqual(['nap:5'], self.run_policy('_bl_game=0; bl_watch_nap')) - def test_one_push_compresses_the_game_and_the_launcher_and_restarts_the_launcher_wait(self): - self.assertEqual(['send:1:game', 'pids:321 654 999', 'client:0:0'], self.run_policy(''' + def test_session_push_preserves_client_timestamps(self): + self.assertEqual(['send:1:session', 'pids:321 654', 'client:-1:-1'], self.run_policy(''' SECONDS=0; bl_session_pressure -bl_zram_pick game; echo "pids:${_pids[*]}" +bl_zram_pick session; echo "pids:${_pids[*]}" echo "client:$_bl_pressure_at:$_bl_packed" ''')) @@ -286,19 +330,19 @@ echo "client:$_bl_pressure_at:$_bl_packed" self.assertEqual(['pids:321 654', 'client:-1:-1'], self.run_policy(''' BL_ZRAM_CLIENT=0 SECONDS=0; bl_session_pressure -bl_zram_pick game; echo "pids:${_pids[*]}" +bl_zram_pick session; echo "pids:${_pids[*]}" echo "client:$_bl_pressure_at:$_bl_packed" ''')[1:]) def test_low_ram_devices_compress_with_a_quarter_of_memory_left(self): self.memory(available=1700) - self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure')) + self.assertEqual(['send:1:session'], self.run_policy('bl_session_pressure')) self.memory(available=1900) self.assertEqual([], self.run_policy('bl_session_pressure')) def test_a_compressed_process_that_exits_is_not_counted_as_read_back(self): - self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy(''' + self.assertEqual(['send:1:session', 'send:1:session', 'backoff:30'], self.run_policy(''' SECONDS=0; bl_session_pressure alive=""; swap=0 SECONDS=30; bl_session_pressure @@ -306,7 +350,7 @@ echo "backoff:$_bl_tier_backoff" ''')) def test_a_process_that_starts_after_a_pageout_is_not_in_its_read_back(self): - self.assertEqual(['send:1:game', 'targets:999', 'send:1:game', 'backoff:30'], self.run_policy(''' + self.assertEqual(['send:1:session', 'targets:321 654', 'send:1:session', 'backoff:30'], self.run_policy(''' SECONDS=0; bl_session_pressure echo "targets:${_bl_tier_targets[*]}" alive="999 1234" @@ -315,7 +359,7 @@ echo "backoff:$_bl_tier_backoff" ''')) def test_an_incomplete_pageout_does_not_restart_the_launcher_wait(self): - self.assertEqual(['send:1:game', 'client:-1:-1'], self.run_policy(''' + self.assertEqual(['send:1:session', 'client:-1:-1'], self.run_policy(''' complete=0 SECONDS=0; bl_session_pressure echo "client:$_bl_pressure_at:$_bl_packed" @@ -323,7 +367,7 @@ echo "client:$_bl_pressure_at:$_bl_packed" def test_a_reused_process_id_is_not_counted_as_read_back(self): - self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy(''' + self.assertEqual(['send:1:session', 'send:1:session', 'backoff:30'], self.run_policy(''' SECONDS=0; bl_session_pressure started=200; swap=0 SECONDS=30; bl_session_pressure @@ -344,5 +388,112 @@ echo "backoff:$_bl_tier_backoff" self.assertEqual(['gone'], result.stdout.split()) +class ReclaimDispatchTest(unittest.TestCase): + def run_cycle(self, client=1, game=1, anonymous=384, complete=1, reclaimed=128, body=None, available=1024, fall=0, tick=0): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'meminfo').write_text( + f'MemTotal: 7340032 kB\nMemAvailable: {available * 1024} kB\n' + 'SwapTotal: 6291456 kB\nSwapFree: 4194304 kB\n') + (root / 'pressure').write_text('some avg10=0.00 avg60=0.00 avg300=0.00 total=0\n') + names = ('bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', + 'bl_zram_has_room', 'bl_client_can_pageout', 'bl_client_pressure', + 'bl_client_pressure_result', 'bl_client_pressure_finish', + 'bl_session_pressure', 'bl_zram_send', 'bl_zram_finish', 'bl_zram_pick') + source = '\n'.join(function_source(name) for name in names) + source = source.replace('/proc/meminfo', str(root / 'meminfo')) + source = source.replace('/proc/pressure/memory', str(root / 'pressure')) + source = source.replace('/tmp/.bl-zram', str(root / 'ctl')) + monitor = SCRIPT.read_text() + start = monitor.index(' _bl_preserved_growth=$_bl_growth', monitor.index('while kill -0 $$')) + stop = monitor.index(' if [ "$_bl_game" = 1 ] && bl_game_focused', start) + cycle = monitor[start:stop] if body is None else body + script = f''' +set -u +BL_ZRAM_CLIENT={client} _bl_game={game} _bl_pids=(123) _bl_others=(321) +_bl_pressure_at=-1 _bl_pressure_backoff=30 _bl_packed=-1 +_bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 +_bl_client_reserve=0 client_rss=393216 client_anon={anonymous * 1024} +step() {{ :; }} +bl_session_others() {{ _bl_session=(321); }} +bl_zram_scan() {{ _bl_pids=(456); }} +nap() {{ echo "nap:$*:at:$NOW"; }} +bl_zram_usage() {{ + if [ "$1" = 123 ]; then + _bl_rss=$client_rss _bl_anon=$client_anon _bl_swap=0 + else + _bl_rss=3145728 _bl_anon=1572864 _bl_swap=0 + fi +}} +kill() {{ echo "signal:$*:at:$NOW"; NOW=$((NOW + {tick})); }} +bl_zram_wait() {{ + echo "wait:$*:at:$NOW" + NOW=$((NOW + 12)) + _bl_pageout_complete={complete} + [ "$1" != 123 ] || client_rss=$((client_rss - {reclaimed * 1024})) +}} +bl_tier_note() {{ :; }} +''' + source + f''' +bl_memory_read +_bl_fall={fall * 1024} +''' + cycle + ''' +echo "client:$_bl_pressure_backoff:${_bl_client_reserve:-0}:${_bl_client_pending:-0}" +''' + result = run_bash('NOW=0\n' + script.replace('SECONDS', 'NOW')) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('', result.stderr) + return result.stdout.splitlines() + + def test_both_requests_are_sent_before_either_wait(self): + self.assertEqual(['signal:-URG 123:at:0', 'signal:-URG 321:at:0', + 'wait:321:at:0', 'wait:123:at:12', 'client:30:0:0'], self.run_cycle()) + + def test_disabled_client_is_never_signalled_or_waited_for(self): + self.assertEqual(['signal:-URG 321:at:0', 'wait:321:at:0', 'client:30:0:0'], + self.run_cycle(client=0)) + + def test_no_game_requests_nothing(self): + self.assertEqual(['client:30:0:0'], self.run_cycle(game=0)) + + def test_a_client_with_no_anonymous_pages_still_finishes_its_request(self): + self.assertEqual(['signal:-URG 123:at:0', 'signal:-URG 321:at:0', + 'wait:321:at:0', 'wait:123:at:12', 'client:30:0:0'], + self.run_cycle(anonymous=0)) + + def test_falling_headroom_survives_a_tick_between_requests(self): + self.assertEqual(['signal:-URG 123:at:0', 'signal:-URG 321:at:1', + 'wait:321:at:2', 'wait:123:at:14', 'client:30:0:0'], + self.run_cycle(available=2304, fall=512, tick=1)) + + def test_deferred_ineffective_reclaim_still_doubles_the_backoff(self): + self.assertEqual('client:60:0:0', self.run_cycle(reclaimed=0)[-1]) + + def test_deferred_incomplete_reclaim_does_not_double_the_backoff(self): + self.assertEqual('client:30:0:0', self.run_cycle(reclaimed=0, complete=0)[-1]) + + def test_explicit_combined_send_keeps_its_synchronous_wait(self): + self.assertEqual(['signal:-URG 321 123:at:0', 'wait:321 123:at:0', 'client:30:0:0'], + self.run_cycle(body='bl_zram_send 1 cap game\n')) + self.assertEqual(['signal:-URG 321:at:0', 'wait:321:at:0', 'client:30:0:0'], + self.run_cycle(client=0, body='bl_zram_send 1 cap game\n')) + + def test_readback_keeps_its_synchronous_delay(self): + self.assertEqual(['signal:-URG 123:at:0', 'nap:5:at:0', 'client:30:0:0'], + self.run_cycle(body='bl_zram_send 0 readback client\n')) + + def test_usage_sums_anonymous_rss_and_falls_back_per_process(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for pid, contents in ((123, 'VmRSS: 2000 kB\nRssAnon: 300 kB\nVmSwap: 100 kB\n'), + (321, 'VmRSS: 4000 kB\nVmSwap: 200 kB\n'), + (654, 'VmRSS: 1000 kB\nRssAnon: 0 kB\n')): + (root / str(pid)).mkdir() + (root / str(pid) / 'status').write_text(contents) + source = function_source('bl_zram_usage').replace('/proc/', str(root) + '/') + result = run_bash(source + '\nbl_zram_usage 123 321 654 > /dev/null; echo "$_bl_rss:$_bl_anon:$_bl_swap"') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('7000:4300:300', result.stdout.strip()) + + if __name__ == '__main__': unittest.main() diff --git a/tools/tests/test_steam_compat_x64.py b/tools/tests/test_steam_compat_x64.py index 9f9abc478..9d9bf109e 100644 --- a/tools/tests/test_steam_compat_x64.py +++ b/tools/tests/test_steam_compat_x64.py @@ -99,6 +99,31 @@ class X64RepointTest(unittest.TestCase): self.assertEqual(COMPAT["x64_repoint"](str(self.root), None), []) self.assertEqual((self.root / "config" / "config.vdf").read_text(), before) + def test_new_owned_shortcut_gets_the_x64_default_and_explicit_choice_stays(self): + app = "2350879490" + chosen = "proton-cachyos-a64" + config = self.root / "config" / "config.vdf" + state = self.root / "droiddeck-compat" + self.assertEqual(COMPAT["register_default"]( + str(config), apps=[app], protect={chosen: "proton-cachyos-a64"}, + default="GE-Proton11-7-a64", directory=str(state), + linux_tool="droiddeck-direct", owned=[app]), "GE-Proton11-7-a64") + self.assertEqual(self.mapping()[app], "GE-Proton11-7-a64") + self.assertEqual(COMPAT["register_default"]( + str(config), apps=[app], protect={chosen: "proton-cachyos-a64"}, + default="GE-Proton11-7-a64", directory=str(state), + linux_tool="droiddeck-direct", owned=[app]), "GE-Proton11-7-a64") + self.assertEqual(self.mapping()[app], "GE-Proton11-7-a64") + tokens = COMPAT["tokenize"](config.read_text()) + mapping = COMPAT["mapping_block"](tokens) + COMPAT["set_mapping"](tokens, mapping, app, chosen) + config.write_text(COMPAT["serialize"](tokens)) + COMPAT["register_default"]( + str(config), apps=[app], protect={chosen: "proton-cachyos-a64"}, + default="GE-Proton11-7-a64", directory=str(state), + linux_tool="droiddeck-direct", owned=[app]) + self.assertEqual(self.mapping()[app], chosen) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_steam_x64_runtime_libs.py b/tools/tests/test_steam_x64_runtime_libs.py new file mode 100644 index 000000000..6f3f71c3f --- /dev/null +++ b/tools/tests/test_steam_x64_runtime_libs.py @@ -0,0 +1,67 @@ +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-x64' +SOURCE = SCRIPT.read_text() +LIBRARY_LOOP = re.search(r'^for d in "\$STEAM_X64"/steamrt64/.*?^done\nFEX_ROOTFS=', SOURCE, re.M | re.S).group() +LIBRARY_LOOP = LIBRARY_LOOP[:LIBRARY_LOOP.rfind('\nFEX_ROOTFS=')] + + +class SteamX64RuntimeLibsTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.steam = self.root / 'Steam' + self.runtime = self.steam / 'steamrt64/pv-runtime/steam-runtime-steamrt' + self.lib = self.runtime / 'steamrt3c_platform_a/files/lib/x86_64-linux-gnu' + self.usr_lib = self.runtime / 'steamrt3c_platform_a/files/usr/lib/x86_64-linux-gnu' + self.lib.mkdir(parents=True) + self.usr_lib.mkdir(parents=True) + self.dest = self.root / 'steamrt-libs' + self.dest.mkdir() + + def run_loop(self): + shell = 'srt_libs="$DEST"\n' + LIBRARY_LOOP + env = dict(os.environ, STEAM_X64=str(self.steam), DEST=str(self.dest)) + return subprocess.run(['bash', '-eu', '-c', shell], env=env, capture_output=True, text=True, timeout=10) + + def test_empty_library_directory_is_skipped(self): + (self.usr_lib / 'libexample.so.1').touch() + result = self.run_loop() + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue((self.dest / 'libexample.so.1').is_symlink()) + + def test_two_platform_versions_with_empty_lib_dirs_use_usr_lib(self): + platform_b = self.runtime / 'steamrt3c_platform_b' + (platform_b / 'files/lib/x86_64-linux-gnu').mkdir(parents=True) + usr_lib_b = platform_b / 'files/usr/lib/x86_64-linux-gnu' + usr_lib_b.mkdir(parents=True) + (self.usr_lib / 'libexample.so.1').touch() + (usr_lib_b / 'libother.so.1').touch() + result = self.run_loop() + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue((self.dest / 'libexample.so.1').is_symlink()) + self.assertTrue((self.dest / 'libother.so.1').is_symlink()) + + def test_stale_literal_glob_link_does_not_abort(self): + (self.dest / '*.so*').symlink_to('/missing/*.so*') + result = self.run_loop() + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue((self.dest / '*.so*').is_symlink()) + + def test_existing_dangling_library_link_is_not_recreated(self): + (self.lib / 'libexample.so.1').touch() + (self.dest / 'libexample.so.1').symlink_to('/missing/libexample.so.1') + result = self.run_loop() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('/missing/libexample.so.1', os.readlink(self.dest / 'libexample.so.1')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_store_launch_ea.py b/tools/tests/test_store_launch_ea.py index 2dd72b324..9096d2d3c 100644 --- a/tools/tests/test_store_launch_ea.py +++ b/tools/tests/test_store_launch_ea.py @@ -35,6 +35,52 @@ class EaHook(unittest.TestCase): self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', 'DisplayName', 'Mass Effect 3'), entries) self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\Respawn\Titanfall2', 'DisplayName', 'Mass Effect 3'), entries) + def test_the_games_exe_maps_to_its_launcher_only(self): + with tempfile.TemporaryDirectory() as tmp: + folder = Path(tmp) / 'Game' + (folder / 'bin').mkdir(parents=True) + for name in ('bin/Game.exe', 'bin/Other.exe', store.LAUNCHER): + (folder / name).write_text('x') + sidecar = {'store': 'ea', 'id': 'Origin.OFR.50.1', 'exe': 'bin/Game.exe', 'launcher': store.LAUNCHER} + (folder / store.SIDECAR).write_text(json.dumps(sidecar)) + self.assertEqual(store.store_launcher(str(folder / 'bin/Game.exe')), str(folder / store.LAUNCHER)) + self.assertEqual(store.store_launcher(str(folder / 'bin/game.exe')), str(folder / store.LAUNCHER)) + self.assertIsNone(store.store_launcher(str(folder / 'bin/Other.exe'))) + self.assertIsNone(store.store_launcher(str(Path(tmp) / 'Elsewhere.exe'))) + (folder / store.SIDECAR).write_text(json.dumps(dict(sidecar, launcher=''))) + self.assertIsNone(store.store_launcher(str(folder / 'bin/Game.exe'))) + + def test_a_symlinked_or_deep_exe_still_finds_its_launcher(self): + with tempfile.TemporaryDirectory() as tmp: + folder = Path(tmp) / 'Game' + deep = folder / 'a/b/c/d/e/f' + deep.mkdir(parents=True) + (deep / 'Game.exe').write_text('x') + (folder / store.LAUNCHER).write_text('x') + sidecar = {'store': 'ea', 'id': 'Origin.OFR.50.1', 'exe': 'a/b/c/d/e/f/Game.exe', 'launcher': store.LAUNCHER} + (folder / store.SIDECAR).write_text(json.dumps(sidecar)) + link = Path(tmp) / 'x/y/z/Link.exe' + link.parent.mkdir(parents=True) + link.symlink_to(deep / 'Game.exe') + expected = str(folder.resolve() / store.LAUNCHER) + self.assertEqual(store.store_launcher(str(deep / 'Game.exe')), expected) + self.assertEqual(store.store_launcher(str(link)), expected) + self.assertIsNone(store.find_store_game([str(deep / 'Game.exe')])) + + def test_a_symlinked_exe_finds_the_sidecar_beside_the_link(self): + with tempfile.TemporaryDirectory() as tmp: + outside = Path(tmp) / 'Real/Game.exe' + outside.parent.mkdir() + outside.write_text('x') + folder = Path(tmp) / 'Game' + folder.mkdir() + (folder / store.LAUNCHER).write_text('x') + (folder / 'Game.exe').symlink_to(outside) + sidecar = {'store': 'ea', 'id': 'Origin.OFR.50.1', 'exe': 'Game.exe', 'launcher': store.LAUNCHER} + (folder / store.SIDECAR).write_text(json.dumps(sidecar)) + self.assertEqual(store.store_launcher(str(folder / 'Game.exe')), str(folder / store.LAUNCHER)) + self.assertIsNone(store.store_launcher(str(outside))) + def test_legacy_games_are_registered_by_content_id(self): sidecar = {'id': 'Origin.OFR.50.0005237', 'title': 'Mass Effect 3 (2012)', 'extra': {'contentIds': '71073,71183, bad id', 'displayName': 'Mass Effect 3', 'locale': 'fr_FR'}} diff --git a/tools/tests/test_venus_preload.py b/tools/tests/test_venus_preload.py new file mode 100644 index 000000000..9f90a78f6 --- /dev/null +++ b/tools/tests/test_venus_preload.py @@ -0,0 +1,66 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +PROGRAM = r""" +#include +#include +int unsetenv(const char *name) { return -1; } +int setenv(const char *name, const char *value, int overwrite) { return -1; } +int putenv(char *string) { return -1; } +int main(void) { + const char *names[] = {"LIBGL_KOPPER_DISABLE", "LIBGL_KOPPER_DISABLE_X", "ZINK_DEBUG", "VN_DEBUG"}; + for (int i = 0; i < 4; i++) { + const char *value = getenv(names[i]); + printf("%s=%s\n", names[i], value ? value : "(unset)"); + } + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc"), "needs cc") +class VenusPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = Path(tempfile.mkdtemp()) + source = cls.tmp / "program.c" + source.write_text(PROGRAM) + cls.program = cls.tmp / "program" + cls.library = cls.tmp / "libvenus.so" + subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.program), str(source)], check=True) + subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-shared", "-fPIC", "-o", str(cls.library), + str(PRELOAD / "venus.c")], check=True) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def values(self, **extra): + env = {"PATH": os.environ.get("PATH", ""), "LD_PRELOAD": str(self.library), **extra} + result = subprocess.run([str(self.program)], env=env, capture_output=True, text=True, check=True) + return dict(line.split("=", 1) for line in result.stdout.splitlines()) + + def test_kopper_is_kept_when_environment_changes_are_refused(self): + values = self.values(VN_DEBUG="vtest", LIBGL_KOPPER_DISABLE="true", LIBGL_KOPPER_DISABLE_X="1", + ZINK_DEBUG="mem") + self.assertEqual(values["LIBGL_KOPPER_DISABLE"], "(unset)") + self.assertEqual(values["LIBGL_KOPPER_DISABLE_X"], "1") + self.assertEqual(values["ZINK_DEBUG"], "mem") + self.assertEqual(values["VN_DEBUG"], "vtest") + + def test_other_sessions_keep_kopper_disabled(self): + self.assertEqual(self.values(LIBGL_KOPPER_DISABLE="true")["LIBGL_KOPPER_DISABLE"], "true") + self.assertEqual(self.values(VN_DEBUG="init", LIBGL_KOPPER_DISABLE="true")["LIBGL_KOPPER_DISABLE"], "true") + + def test_absent_variable_is_harmless(self): + self.assertEqual(self.values(VN_DEBUG="vtest")["LIBGL_KOPPER_DISABLE"], "(unset)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_wayland_clock_domain.py b/tools/tests/test_wayland_clock_domain.py new file mode 100644 index 000000000..a805ff9c0 --- /dev/null +++ b/tools/tests/test_wayland_clock_domain.py @@ -0,0 +1,267 @@ +import ctypes +import mmap +import shutil +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest + +from tools.tests.test_clock_pause_preload import clock_reset_command +from tools.tests.test_output_refresh import extract_function as extract_void_function +from tools.tests.test_scene_limit_hold import extract_function + +SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/waylandcomp/src/clock_domain.c" +HEADER = SOURCE.with_suffix(".h") + + +@unittest.skipUnless(shutil.which("cc"), "needs a C compiler") +class WaylandClockDomainTest(unittest.TestCase): + def test_host_timestamp_uses_the_guest_domain(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "clock" + control.write_bytes(struct.pack("\n' + '#include "clock_domain.h"\n' + 'int main(int argc, char **argv) { (void)argc;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld %u %lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL), ' + ' droiddeck_clock_domain_now_ms(), (long long)droiddeck_clock_domain_adjust_ns(5LL * 1000000000LL));\n' + ' return 0;\n' + '}\n' + ) + binary = root / "program" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-O2", "-I", str(SOURCE.parent), "-o", str(binary), str(program), str(SOURCE)], + check=True) + result = subprocess.run([str(binary), str(control)], capture_output=True, text=True, check=True) + adjusted, now_ms, clamped = map(int, result.stdout.split()) + self.assertEqual(adjusted, 20_000_000_000) + self.assertGreaterEqual(now_ms, 0) + self.assertLessEqual(now_ms, 0xFFFFFFFF) + self.assertEqual(clamped, 0) + + def test_paced_release_uses_kernel_time_across_pause_offsets(self): + compositor = SOURCE.with_name("compositor.c").read_text() + functions = extract_function(compositor, "now_ns") + "\n" + extract_void_function(compositor, "arm_release_timer") + harness = r''' +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include "clock_domain.h" + +struct pending_release { void *buffer; int64_t at_ns; struct pending_release *next; }; +static struct pending_release *g_pending_releases; +static int g_release_timer_fd; +#define wl_list_for_each(pr, list, link) for (pr = *(list); pr; pr = pr->next) +static int buffer_release_blocked(void *buffer) { return buffer != NULL; } + +@FUNCTIONS@ + +int main(int argc, char **argv) { + (void)argc; + droiddeck_clock_domain_set_control(argv[1]); + int control = open(argv[1], O_WRONLY); + assert(control >= 0); + g_release_timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); + assert(g_release_timer_fd >= 0); + const int64_t offsets[] = {0, 10000000000LL, 300000000000LL}; + for (unsigned i = 0; i < sizeof(offsets) / sizeof(offsets[0]); i++) { + assert(pwrite(control, &offsets[i], sizeof(offsets[i]), 0) == sizeof(offsets[i])); + struct timespec started, ended; + clock_gettime(CLOCK_MONOTONIC, &started); + int64_t start = now_ns(); + struct pending_release blocked = {(void *)1, start + 1000000LL, NULL}; + struct pending_release release = {NULL, start + 60000000LL, &blocked}; + g_pending_releases = &release; + arm_release_timer(); + int64_t next_offset = offsets[i] + 10000000000LL; + assert(pwrite(control, &next_offset, sizeof(next_offset), 0) == sizeof(next_offset)); + struct pollfd pollfd = {g_release_timer_fd, POLLIN, 0}; + assert(poll(&pollfd, 1, 1000) == 1); + clock_gettime(CLOCK_MONOTONIC, &ended); + int64_t elapsed = (ended.tv_sec - started.tv_sec) * 1000000000LL + ended.tv_nsec - started.tv_nsec; + assert(elapsed >= 50000000LL && elapsed < 1000000000LL); + uint64_t expirations; + assert(read(g_release_timer_fd, &expirations, sizeof(expirations)) == sizeof(expirations)); + assert(expirations == 1); + g_pending_releases = &blocked; + arm_release_timer(); + struct itimerspec remaining; + assert(timerfd_gettime(g_release_timer_fd, &remaining) == 0); + assert(remaining.it_value.tv_sec == 0 && remaining.it_value.tv_nsec == 0); + } + close(g_release_timer_fd); + close(control); + return 0; +} +''' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "clock" + control.write_bytes(struct.pack("\n' + '#include "clock_domain.h"\n' + 'int main(int argc, char **argv) { (void)argc;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' droiddeck_clock_domain_set_control(argv[2]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' return 0;\n' + '}\n' + ) + binary = root / "program" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-O2", "-I", str(SOURCE.parent), "-o", str(binary), str(program), str(SOURCE)], + check=True) + result = subprocess.run([str(binary), str(control), str(root / "missing")], capture_output=True, text=True, check=True) + self.assertEqual(result.stdout.split(), ["30000000000", "30000000000"]) + + def test_replaced_control_file_updates_offset_without_invalidating_readers(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "clock" + replacement = root / "replacement" + control.write_bytes(struct.pack("\n' + '#include "clock_domain.h"\n' + 'int main(int argc, char **argv) { (void)argc;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' if (rename(argv[2], argv[1]) != 0) return 1;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' return 0;\n' + '}\n' + ) + binary = root / "program" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-O2", "-I", str(SOURCE.parent), "-o", str(binary), str(program), str(SOURCE)], + check=True) + result = subprocess.run([str(binary), str(control), str(replacement)], capture_output=True, text=True, check=True) + self.assertEqual(result.stdout.split(), ["20000000000", "10000000000"]) + + def test_session_transition_clears_stale_offset_but_live_failures_preserve_it(self): + harness = r''' +#include +#include +#include +#include +#include "clock_domain.h" + +int main(int argc, char **argv) { + (void)argc; + int64_t shift = 10000000000LL; + int fd = open(argv[1], O_CREAT | O_RDWR, 0600); + assert(fd >= 0); + assert(write(fd, &shift, sizeof(shift)) == sizeof(shift)); + droiddeck_clock_domain_set_control(argv[1]); + assert(droiddeck_clock_domain_adjust_ns(30000000000LL) == 20000000000LL); + assert(unlink(argv[1]) == 0); + droiddeck_clock_domain_set_control(argv[1]); + assert(droiddeck_clock_domain_adjust_ns(30000000000LL) == 20000000000LL); + int replacement = open(argv[1], O_CREAT | O_RDWR, 0600); + assert(replacement >= 0); + droiddeck_clock_domain_set_control(argv[1]); + assert(droiddeck_clock_domain_adjust_ns(30000000000LL) == 20000000000LL); + droiddeck_clock_domain_set_control(NULL); + droiddeck_clock_domain_set_control(argv[1]); + assert(droiddeck_clock_domain_adjust_ns(30000000000LL) == 30000000000LL); + shift = 20000000000LL; + assert(write(replacement, &shift, sizeof(shift)) == sizeof(shift)); + droiddeck_clock_domain_set_control(argv[1]); + assert(droiddeck_clock_domain_adjust_ns(30000000000LL) == 10000000000LL); + close(replacement); + close(fd); + return 0; +} +''' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + program = root / "program.c" + program.write_text(harness) + binary = root / "program" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-O2", "-I", str(SOURCE.parent), + "-o", str(binary), str(program), str(SOURCE)], check=True) + subprocess.run([str(binary), str(root / "clock")], check=True, timeout=5) + host = SOURCE.parents[3] / "java/com/droiddeck/launcher/wayland/CompositorHost.kt" + self.assertIn("nativeSetClockControl(null)", host.read_text().split("fun newSession() {", 1)[1].split("}", 1)[0]) + + def test_startup_reset_keeps_host_and_guest_on_the_same_inode(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "clock" + control.write_bytes(struct.pack("\n' + '#include "clock_domain.h"\n' + 'int main(int argc, char **argv) { (void)argc;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' if (rename(argv[2], argv[1]) != 0) return 1;\n' + ' droiddeck_clock_domain_set_control(argv[1]);\n' + ' printf("%lld\\n", (long long)droiddeck_clock_domain_adjust_ns(30LL * 1000000000LL));\n' + ' return 0;\n' + '}\n' + ) + binary = root / "program" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-O2", "-I", str(SOURCE.parent), + "-o", str(binary), str(program), str(SOURCE)], check=True) + result = subprocess.run([str(binary), str(control), str(replacement)], capture_output=True, text=True, check=True) + self.assertEqual(result.stdout.split(), ["30000000000", "10000000000"]) + + +if __name__ == "__main__": + unittest.main()