Compare commits

...
8 changed files with 102 additions and 28 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ captures/
# copies cannot drift.
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
@@ -126,7 +126,8 @@ class MainActivity : ComponentActivity() {
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -503,8 +504,9 @@ class MainActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
// The microphone is on by default; ask once, with the storage prompt, so voice chat works
// without a trip to the settings. A refusal is not asked again - the toggle asks when used.
// The microphone is off by default. Someone who turned it on before the permission was
// granted is asked once here, with the storage prompt; a refusal is not asked again - the
// toggle asks when used.
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
@@ -893,6 +895,7 @@ class MainActivity : ComponentActivity() {
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
@@ -900,6 +903,7 @@ class MainActivity : ComponentActivity() {
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -944,6 +948,7 @@ class MainActivity : ComponentActivity() {
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
@@ -993,6 +998,7 @@ class MainActivity : ComponentActivity() {
tuSysmem = SessionPrefs.tuSysmem(this)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -146,6 +146,7 @@ object DeviceReport {
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
@@ -143,8 +143,8 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
@@ -188,14 +188,28 @@ object SessionPrefs {
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears.
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
@@ -530,9 +530,12 @@ class SessionService : Service() {
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
// a slice of a core for gamepad navigation the session already has.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -37,6 +37,7 @@ fun PerformancePage(
glThread: Boolean,
noGlError: Boolean,
noXalia: Boolean,
gamescopeRealtime: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
@@ -46,6 +47,7 @@ fun PerformancePage(
onGlThread: (Boolean) -> Unit,
onNoGlError: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onGamescopeRealtime: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
@@ -94,6 +96,11 @@ fun PerformancePage(
"Disables per-call GL validation.",
noGlError, onChange = onNoGlError,
)
ToggleRow(
host, "gsrealtime", "gamescope: realtime GPU queue",
"Gives the compositor's GPU work priority over the game's. Can smooth frame pacing, but can cost games GPU time.",
gamescopeRealtime, onChange = onGamescopeRealtime,
)
}
SettingsGroup("Session fixes") {
ToggleRow(
@@ -103,7 +110,7 @@ fun PerformancePage(
)
ToggleRow(
host, "xalia", "Skip Steam's xalia helper",
"Disables Proton's gamepad navigation helper. Try if sessions crash at startup.",
"Disables Proton's gamepad navigation helper, which costs every game CPU time. Turn off only if a game needs it.",
noXalia, onChange = onNoXalia,
)
ToggleRow(
+57 -16
View File
@@ -89,7 +89,17 @@ linuxfs_replaced=1
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
# Docker Desktop's VM restarts now and then, and until its engine has loaded its image store it
# answers "No such image" for images it has. The rebuild that follows hangs on the registry
# (the base image's credentials go through docker-credential-desktop), so give a restarting daemon
# up to a minute before deciding the image is really missing.
image_present=0
for _ in $(seq 1 30); do
if inspect_error=$(docker image inspect "${image_name}" 2>&1 >/dev/null); then image_present=1; break; fi
sleep 2
done
if [[ "${image_present}" = 0 ]]; then
echo "Build image ${image_name} not found (${inspect_error:-no error}); building it." >&2
docker build --platform linux/amd64 -t "${image_name}" \
-f "${repo_root}/tools/local-cross.Dockerfile" "${repo_root}"
fi
@@ -164,20 +174,39 @@ if [[ -z "${github_repo}" ]]; then
github_repo=${github_repo%.git}
fi
# Pinned downloads are kept between builds, named by their checksum, so a rebuild fetches nothing
# it already has. A cached file is checked again before use; a bad one is fetched anew.
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache_dir}"
# cached <sha256> <name> <command...>: prints the cached path, running the command (which writes
# to "$out") only when the cache has no file with that checksum.
cached() {
local sha=$1 name=$2 out
shift 2
out="${cache_dir}/${sha}-${name}"
if [[ -f "${out}" ]] && printf '%s %s\n' "${sha}" "${out}" | shasum -a 256 -c - >/dev/null 2>&1; then
echo "${out}"
return 0
fi
rm -f "${out}.part"
out="${out}.part" "$@" >&2
printf '%s %s\n' "${sha}" "${out}.part" | shasum -a 256 -c - >&2
mv "${out}.part" "${out}"
echo "${out}"
}
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive="${staging_dir}/gamescope.tzst"
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
. "${repo_root}/tools/wlroots/release.env"
wlroots_archive="${staging_dir}/wlroots.tzst"
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
wlroots_archive=$(cached "${WLROOTS_SHA256}" wlroots.tzst \
bash -c 'gh release download "$0" -R "$1" -p wlroots.tzst -O "$out"' "${WLROOTS_TAG}" "${github_repo}")
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
fi
@@ -187,9 +216,8 @@ mango_pkgs="${staging_dir}/mango-pkgs"
mkdir -p "${mango_dir}" "${mango_pkgs}"
while read -r package_sha256 package_url; do
[[ -n "${package_url}" ]] || continue
package_archive="${staging_dir}/$(basename "${package_url}")"
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
package_archive=$(cached "${package_sha256}" "$(basename "${package_url}")" \
bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "${package_url}")
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
@@ -201,11 +229,14 @@ install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/b
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
pa_tarball="${cache_dir}/pulseaudio-13.0.tar.gz"
if [[ ! -s "${pa_tarball}" ]] || ! tar -tzf "${pa_tarball}" >/dev/null 2>&1; then
curl -fsSL -o "${pa_tarball}.part" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mv "${pa_tarball}.part" "${pa_tarball}"
fi
mkdir -p "${pa_source}"
tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \
-C "${pa_source}" --strip-components=1
tar -xzf "${pa_tarball}" -C "${pa_source}" --strip-components=1
fi
if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
echo "PulseAudio 13.0 source not found at ${pa_source}; set DROIDDECK_PA13_SOURCE_DIR." >&2
@@ -214,7 +245,17 @@ fi
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since
# the libraries in jniLibs were built.
proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a"
proot_inputs=$(cd "${repo_root}/tools/proot" && find . -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1)
if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
&& "$(cat "${proot_out}/.proot-inputs" 2>/dev/null)" = "${proot_inputs}" ]]; then
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
else
"${repo_root}/tools/proot/build.sh" "${proot_out}"
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
fi
cp -p "${bundle_asset}" "${bundle_backup}"
bundle_dir="${staging_dir}/pulseaudio-bundle"
@@ -229,7 +270,7 @@ install -m755 "${sink_output}/module-aaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst"
tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst"
bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
@@ -77,8 +77,9 @@ fi
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it
# changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context
# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it.
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1}
# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off
# unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1).
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's
# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60