From 38c9b733b5fdff156e358f78e24efc9597e00011 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:26:39 -0400 Subject: [PATCH 1/3] Feat/step out stop confirm (#105) --- .../com/droiddeck/launcher/MainActivity.kt | 3 + .../com/droiddeck/launcher/SessionActivity.kt | 3 + .../droiddeck/launcher/ui/ComponentsPage.kt | 8 +- .../com/droiddeck/launcher/ui/DriverPage.kt | 8 +- .../com/droiddeck/launcher/ui/FocusGlide.kt | 253 +++++++++++++++ .../com/droiddeck/launcher/ui/FrontEndArt.kt | 3 +- .../droiddeck/launcher/ui/FrontEndContent.kt | 2 +- .../droiddeck/launcher/ui/FrontEndGames.kt | 2 +- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 5 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 5 +- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 10 +- .../com/droiddeck/launcher/ui/PcKeyboard.kt | 5 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 307 +++++++++++++++--- .../droiddeck/launcher/ui/SettingsWidgets.kt | 8 +- .../com/droiddeck/launcher/ui/StorePage.kt | 8 +- .../com/droiddeck/launcher/ui/UpdatesPage.kt | 7 +- .../launcher/ui/WirelessAdbFixPage.kt | 2 +- 17 files changed, 551 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ea1bad720..ae962bb76 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.Manifest import android.app.ActivityOptions import android.content.Intent @@ -317,6 +318,7 @@ class MainActivity : ComponentActivity() { updates.start() setContent { DroidDeckTheme(theme) { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { val sm = settingsMode val page: (@Composable () -> Unit)? = when { sm != null -> { { ModeSettingsHost(sm) } } @@ -566,6 +568,7 @@ class MainActivity : ComponentActivity() { } } } + } if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2023616d3..1c58332a7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.hardware.input.InputManager import android.hardware.display.DisplayManager import android.content.Intent @@ -315,6 +316,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) @@ -445,6 +447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } } + } } private fun refreshHomeApp() { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index d0b4a4fa6..566f66b4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -304,7 +304,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp)) + .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick), ) { @@ -327,7 +327,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) - .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), @@ -369,7 +369,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE) + .glideBorder(hot, ROW_SHAPE, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), @@ -398,7 +398,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE) + .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index df76a1e81..6a8809088 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -92,7 +92,7 @@ fun DriverPage( contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) + .glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line) .focusRequester(refreshFocus) .hoverable(refreshSrc) .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), @@ -173,7 +173,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), + .glideBorder(hot, shape, pal.signal), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -209,7 +209,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp)) + .glideBorder(delHot, RoundedCornerShape(10.dp), colors.error) .focusRequester(delFocus) .focusProperties { left = mainFocus } .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete), @@ -229,7 +229,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color, modifier = modifier.clip(shape) .background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (hot) color else Color.Transparent, shape) + .glideBorder(hot, shape, color) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt new file mode 100644 index 000000000..6779032e1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -0,0 +1,253 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.abs + +// One focus ring for a whole screen that slides from control to control. When focus moves, the +// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so +// the ring stretches like a drop and snaps into the new control's shape. + +/** A control that can show the ring: where it is and what its ring looks like there. */ +internal class GlideSource { + var coords: LayoutCoordinates? = null + var shape: Shape by mutableStateOf(androidx.compose.ui.graphics.RectangleShape) + var color by mutableStateOf(Color.Transparent) + var width by mutableStateOf(2.dp) +} + +internal class FocusGlide(val view: View) { + var host: LayoutCoordinates? = null + /** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */ + val hot = mutableStateListOf() + /** Bumped when the current control moves (a scroll, a relayout). */ + var moves by mutableIntStateOf(0) + + val current: GlideSource? get() = hot.lastOrNull() + + fun claim(s: GlideSource) { hot.remove(s); hot.add(s) } + fun release(s: GlideSource) { hot.remove(s) } + fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ } + + /** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */ + fun boundsOf(s: GlideSource): Rect? { + val h = host ?: return null + val c = s.coords ?: return null + if (!h.isAttached || !c.isAttached) return null + val b = h.localBoundingBoxOf(c, clipBounds = true) + return if (b.width < 1f || b.height < 1f) null else b + } +} + +internal val LocalFocusGlide = staticCompositionLocalOf { null } + +private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float = + when (val o = shape.createOutline(size, dir, density)) { + is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x + is Outline.Rectangle -> 0f + is Outline.Generic -> minOf(size.width, size.height) / 2f + } + +/** + * A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot]. + * Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a + * popup, a screen with no host) it is drawn here, as a plain border. + */ +@Composable +internal fun Modifier.glideBorder( + hot: Boolean, + shape: Shape, + hotColor: Color, + restColor: Color = Color.Transparent, + hotWidth: Dp = 2.dp, + restWidth: Dp = 1.dp, +): Modifier { + val glide = LocalFocusGlide.current + if (glide == null || glide.view !== LocalView.current) { + return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape) + } + val src = remember { GlideSource() } + src.shape = shape + src.color = hotColor + src.width = hotWidth + DisposableEffect(glide, hot) { + if (hot) glide.claim(src) + onDispose { glide.release(src) } + } + val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this + return base.onGloballyPositioned { src.coords = it; glide.moved(src) } +} + +/** Leading edge on a focus move: quick and a little loose. */ +private fun lead(): AnimationSpec = Motion.sp(0.62f, 700f) +/** Trailing edge: slower, settles without a bounce to speak of. */ +private fun trail(): AnimationSpec = Motion.sp(0.78f, 360f) +/** Following a control that moves under the ring (a scroll): tight, no wobble. */ +private fun follow(): AnimationSpec = Motion.sp(1f, 2400f) + +/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */ +private const val SETTLE_MS = 450L + +/** + * Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the + * host's space, so put the host inside anything that moves as a whole (a sheet that slides in). + */ +@Composable +internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val view = LocalView.current + val density = LocalDensity.current + val dir = LocalLayoutDirection.current + val glide = remember(view) { FocusGlide(view) } + val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom + val corner = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val jobs = remember { arrayOfNulls(5) } + val cur = glide.current + var lastColor by remember { mutableStateOf(Color.Transparent) } + if (cur != null) lastColor = cur.color + val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor") + var width by remember { mutableStateOf(2.dp) } + if (cur != null) width = cur.width + + LaunchedEffect(glide) { + var shown: GlideSource? = null + var movedAt = 0L + var dirSpecs: List> = List(4) { follow() } + var delays = LongArray(4) + fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { + jobs[i]?.cancel() + jobs[i] = scope.launch { + if (wait > 0) delay(Motion.ms(wait.toInt()).toLong()) + edges[i].animateTo(v, spec) + } + } + fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) { + val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size + val r = cornerOf(s.shape, size, dir, density) + jobs[4]?.cancel() + jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) } + } + snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) -> + if (src == null) { + // Focus passing between two controls can let go of one a moment before the next + // takes it: wait a beat, so that reads as a move and not a fade out and back in. + delay(60) + scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + shown = null + return@collectLatest + } + var first = src !== shown + val start = System.nanoTime() + var last: Rect? = null + while (true) { + val b = glide.boundsOf(src) + if (b == null) { + if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + } else if (first) { + val prev = shown + val wasShowing = prev != null && alpha.targetValue > 0f + shown = src + first = false + if (!wasShowing) { + // Appearing: a hair bigger, pulled in onto the control as it fades up. + val out = with(density) { 3.dp.toPx() } + val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out) + jobs.forEach { it?.cancel() } + scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join() + dirSpecs = List(4) { Motion.sp(0.6f, 700f) } + delays = LongArray(4) + cornerTo(src, b, snap = true) + scope.launch { alpha.animateTo(1f, Motion.tw(140)) } + } else { + // Moving: the edges on the side it is heading lead. + val dx = b.center.x - (edges[0].value + edges[2].value) / 2f + val dy = b.center.y - (edges[1].value + edges[3].value) / 2f + val horizontal = abs(dx) >= abs(dy) + val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1) + val trailIdx = (leadIdx + 2) % 4 + dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } } + delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L } + cornerTo(src, b, snap = false) + scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + } + movedAt = System.nanoTime() + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) } + last = b + } else if (b != last) { + // It moved under the ring: keep the stretch while the move is fresh, else follow. + val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) } + if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + last = b + } + // Layer animations (a tile growing, a row sliding) move it without a relayout, so + // look again each frame for a moment after anything changes. + if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break + withFrameNanos { } + } + } + } + + Box( + modifier + .onGloballyPositioned { glide.host = it } + .drawWithContent { + drawContent() + val a = alpha.value + if (a <= 0.01f) return@drawWithContent + val w = width.toPx() + val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value + val sw = (r - l - w).coerceAtLeast(0f) + val sh = (b - t - w).coerceAtLeast(0f) + val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f) + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, + ) + }, + ) { + CompositionLocalProvider(LocalFocusGlide provides glide) { content() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index a49df3698..64e1bfc60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") val pal = LocalPalette.current - val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") Column( modifier = track .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } .clip(Shape12) .background(colors.surface) - .border(2.5.dp, ring, Shape12) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) .alpha(if (t.dim && !hot) 0.55f else 1f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 400c4b4b3..ccfe357ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 3c279aa87..ea67f9cd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") .clip(Shape12) .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) + .glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) .controllerConfirm(onClick = onLaunch) .padding(horizontal = 10.dp, vertical = 6.dp), 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 e1cd31996..18630a028 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -201,7 +201,6 @@ private fun RailItem( if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) - val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") Box( contentAlignment = Alignment.Center, @@ -211,7 +210,7 @@ private fun RailItem( .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) .background(fill) - .border(2.dp, ring, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), @@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on .width(if (iconOnly) 52.dp else 80.dp) .clip(Shape14) .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) - .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) .semantics { contentDescription = "Resume $name" } 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 048a86aac..bc51f4036 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -370,7 +370,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) .controllerConfirm(onClick = t.onClick) .padding(horizontal = 14.dp, vertical = 12.dp), @@ -425,14 +425,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi val hot = rememberHot(src) val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") Column( verticalArrangement = Arrangement.spacedBy(3.dp), modifier = modifier.paneItem(id) .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, edge, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..8f1f8b3e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp) .clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) + .glideBorder(hot, shape, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 8.dp, end = 16.dp), @@ -180,7 +180,7 @@ internal fun PrimaryButton( .shine(hot, 0.45f) // The grow and shine alone barely show on the light fill: outline it when a // controller is on it, as the other controls are. - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() @@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") Box( contentAlignment = Alignment.Center, - modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) val hot = rememberHot(src) val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) + modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index ad3b34405..3f7a03558 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -202,12 +202,11 @@ private fun RowScope.KeyCap( else -> Color.White.copy(alpha = 0.07f) }, label = "keyFill", ) - val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f) Box( contentAlignment = Alignment.Center, modifier = modifier.weight(weight).fillMaxHeight() .background(fill, RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume() @@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxHeight() .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f)) .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .padding(horizontal = 12.dp), ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index f02b05134..501dd69b9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,6 +1,28 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.draw.alpha +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -195,6 +215,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } + var sheetCoords by remember { mutableStateOf(null) } + var stopCoords by remember { mutableStateOf(null) } val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } @@ -240,10 +262,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { + FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier - .fillMaxHeight() - .width(sheetWidth) + .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .focusGroup() @@ -255,7 +277,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) { Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true } + StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true } } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -437,6 +459,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } } + StopConfirm( + open = confirmStop, + pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } }, + controllerActive = controllerActive, + onCancel = { confirmStop = false }, + onStop = { onScreen -> + confirmStop = false + val from = a.onStopFrom + if (from != null) from(onScreen) else a.onStop() + }, + ) + } } } } @@ -457,44 +491,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onDismiss = { appToChooseDisplay = null }, ) } - if (confirmStop) { - val cancelFocus = remember { FocusRequester() } - val cancel = { confirmStop = false } - // Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken - // against the screen and the session's overlay converts them back. - var stopOnScreen by remember { mutableStateOf(null) } - val stop = { - confirmStop = false - val from = a.onStopFrom - if (from != null) from(stopOnScreen) else a.onStop() - } - LaunchedEffect(controllerActive) { - if (controllerActive) { - androidx.compose.runtime.withFrameNanos { } - runCatching { cancelFocus.requestFocus() } - } - } - AlertDialog( - onDismissRequest = cancel, - modifier = Modifier.controllerBack(onBack = cancel), - title = { Text("Stop session?") }, - confirmButton = { - val dialogView = LocalView.current - TextButton( - onClick = stop, - modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c -> - val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) } - stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat()) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colors.error), - ) { Text("Stop") } - }, - dismissButton = { - TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus) - .controllerConfirm(onClick = cancel)) { Text("Cancel") } - }, - ) - } } @Composable @@ -524,7 +520,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" } - .clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape) + .clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 12.dp, end = 14.dp), @@ -549,7 +545,7 @@ private fun QuickAction( val shape = RoundedCornerShape(12.dp) val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) .clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onConfirm ?: onClick) @@ -580,7 +576,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier Box( contentAlignment = Alignment.Center, modifier = modifier.size(44.dp).clip(ring) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ring) + .glideBorder(hot, ring, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .controllerConfirm(onClick = onClick) .semantics { contentDescription = description }, @@ -639,7 +635,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), ), ) - .border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp)) + .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) .semantics { contentDescription = "${drawerPageTitles[index]} page" } .hoverable(source) .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) @@ -788,3 +784,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f private const val DRAWER_TAB_SIDE_SCALE = 0.5f private const val DRAWER_TAB_SIDE_ALPHA = 0.7f + +/** Room between the Stop pill and the box that steps out under it: the pull. */ +private val StepPull = 6.dp +private val StepBoxCorner = 16.dp +private val StepFillet = 10.dp +private val StepBoxMaxWidth = 264.dp + +/** + * The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge + * and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and + * the inside corner where the pull meets the box is rounded the other way. While the box is still + * no wider than the pill it is just the pill, stretched down. + */ +private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) { + path.reset() + val r = p.right; val tl = p.left; val tt = p.top + val pb = maxOf(bottom, p.bottom) + val l = minOf(left, tl) + val pt = minOf(pullTop, pb) + val sw = tl - l + val h = (pb - pt).coerceAtLeast(0f) + val rt = minOf(p.height / 2f, p.width / 2f) + val rbr = minOf(box, (pb - tt) / 2f) + val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f) + val rpl = minOf(box, sw / 2f, h / 2f) + val f = minOf(fillet, sw / 2f) + val yl = minOf(pt + rpl, pb - rbl) + fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) { + if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false) + } + path.moveTo(tl + rt, tt) + path.lineTo(r - rt, tt) + corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt) + path.lineTo(r, pb - rbr) + corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb) + path.lineTo(l + rbl, pb) + corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl) + path.lineTo(l, yl) + corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt) + path.lineTo(tl - f, pt) + corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f) + path.lineTo(tl, tt + rt) + corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt) + path.close() +} + +/** + * Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width + * drops out of it and a wider box steps out to the left under it for the choices, right edges + * flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill, + * width first. [onStop] gets Stop's bounds on screen for the flood. + */ +@Composable +private fun StopConfirm( + open: Boolean, + pill: Rect?, + controllerActive: Boolean, + onCancel: () -> Unit, + onStop: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val left = remember { Animatable(0f) } + val bottom = remember { Animatable(0f) } + val tint = remember { Animatable(0f) } + val dim = remember { Animatable(0f) } + val items = remember { List(3) { Animatable(0f) } } + // Composed from the first open until it has folded back into the pill. + var shown by remember { mutableStateOf(false) } + var grown by remember { mutableStateOf(false) } + var content by remember { mutableStateOf(null) } + val cancelFocus = remember { FocusRequester() } + var stopFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + var stopCoords by remember { mutableStateOf(null) } + val view = LocalView.current + if (!open && !shown) return + val pull = with(density) { StepPull.toPx() } + + LaunchedEffect(open, pill, content) { + val p = pill ?: return@LaunchedEffect + val c = content ?: return@LaunchedEffect + val boxLeft = (p.right - c.width).coerceAtLeast(0f) + val boxBottom = p.bottom + pull + c.height + if (open) { + shown = true + if (!grown) { + left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f) + items.forEach { it.snapTo(0f) } + grown = true + } + coroutineScope { + launch { dim.animateTo(1f, Motion.tw(260)) } + launch { tint.animateTo(1f, Motion.tw(200)) } + launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) } + launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) } + items.forEachIndexed { i, a -> + launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) } + } + if (controllerActive) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { cancelFocus.requestFocus() } + } + } + } else if (grown) { + coroutineScope { + items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) } + } + grown = false + shown = false + content = null + } + } + val p = pill ?: return + + Box(Modifier.fillMaxSize()) { + // The rest of the sheet dims behind it, and a tap there cancels. + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel), + ) + val path = remember { Path() } + val boxCorner = with(density) { StepBoxCorner.toPx() } + val fillet = with(density) { StepFillet.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val restFill = colors.surface + val hotFill = lerp(colors.surface, colors.error, 0.22f) + Canvas(Modifier.fillMaxSize()) { + if (!grown) return@Canvas + stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet) + drawPath(path, lerp(hotFill, restFill, tint.value)) + drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim)) + } + // The pill's label rides on top, as the pull's handle. + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) } + .size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() }) + .padding(start = 12.dp, end = 14.dp), + ) { + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) + Column( + modifier = Modifier + .offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) } + .width(with(density) { maxW.toDp() }) + .onSizeChanged { if (content != it) content = it } + .controllerBack(onCancel) + .onPreviewKeyEvent { e -> + // Keep a controller in here, as the dialog's own window used to. + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp, Key.DirectionDown -> true + Key.DirectionLeft -> cancelFocused + Key.DirectionRight -> stopFocused + else -> false + } + } + .padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp), + ) { + Text( + "Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) { + ConfirmChoice( + "Cancel", danger = false, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() } + .focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }, + onClick = onCancel, + ) + ConfirmChoice( + "Stop", danger = true, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() } + .onFocusChanged { stopFocused = it.isFocused } + .onGloballyPositioned { stopCoords = it }, + ) { + val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) } + onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat())) + } + } + } + } +} + +/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */ +@Composable +private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 40.dp).clip(shape) + .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) + .glideBorder(hot, shape, accent) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 1b36b1c4b..b332958d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -148,7 +148,7 @@ internal fun TabStrip( .then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier) .clip(tabShape) .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape) + .glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) .padding(horizontal = 16.dp, vertical = 12.dp), @@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val pal = LocalPalette.current - val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge") val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo .heightIn(min = 44.dp) .clip(RoundedCornerShape(10.dp)) .background(colors.surfaceVariant) - .border(1.dp, edge, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack") - val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge") val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob") val flip = { onChange(!checked) } val shape = RoundedCornerShape(99.dp) @@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul ) { Box( Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track) - .border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape), + .glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2), ) { Box( Modifier.padding(4.dp).size(22.dp) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index da057cb72..5c6002f68 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, modifier = Modifier.paneItem("pill:$label").clip(shape) .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 9.dp), @@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}") .clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14) + .glideBorder(hot, Shape14, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { 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 936ce1555..6d824a604 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -213,7 +213,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) { Text( if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more") - .clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .clip(Shape12).glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) .controllerConfirm(onClick = toggle) .padding(horizontal = 6.dp, vertical = 4.dp), @@ -273,13 +273,12 @@ private fun ChannelCard( val src = remember { MutableInteractionSource() } val hot = rememberHot(src) // Focus shows at once, as on the Components page: no ripple, no fade. - val edge = if (hot || selected) pal.signal else pal.line val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().paneItem("channel:$title") - .clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14) + .clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line) .alpha(if (enabled) 1f else 0.55f) .hoverable(src) .clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick) @@ -316,7 +315,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) { .heightIn(min = 52.dp) .clip(Shape12) .background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12) + .glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line) .hoverable(src) .clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) .controllerConfirm(onClick = onClick) 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 52cde55e8..a067ef020 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -381,7 +381,7 @@ internal fun AdbTextField( .fillMaxWidth() .height(inputHeight) .background(colors.surfaceVariant, shape) - .border(1.dp, if (focused) palette.signal else palette.line2, shape) + .glideBorder(focused, shape, palette.signal, palette.line2) .padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart, ) { From 6c5d6cda73a2a65ba89affc1e617c65f63a40714 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Wed, 30 Sep 2026 16:40:46 -0400 Subject: [PATCH 2/3] Added LSFG/Import/License Checking (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lsfg): take Lossless Scaling from Steam by itself, import it once owned, rebuild on update - Lossless.kt owns the DLL and its shader cache. The copy the Steam client installs (runtime library or the card's) is taken by itself and taken again when the client updates it (size/mtime stamp). The DLL is copied in and built into a cache named after its SHA-256, so a new DLL hands the compositor a new path; a build that fails keeps the working cache. Builds are serialized. - The Steam session asks the signed-in client once per session whether the account owns app 993090 until the answer is yes, then records it and the app stops asking (BL_LOSSLESS_OWNED). Once owned, the frame generation menu offers "Import Lossless.dll…", on the main screen and in the session drawer; an import stays until Steam's copy changes. - The compositor restarts the LSFG engine when its cache path changes, and a new engine selection clears an earlier start failure, so an updated DLL or a retry no longer needs the app restarted. - The HUD says when LSFG is waiting on Lossless Scaling instead of "starting"; frame generation strings are resources. - Removed LsfgNative's unused DLL variant and cache-match calls. * Find Lossless.dll in added Games libraries too * LSFG: 120, 90 and 60 fps targets alongside 2x-4x The pacer's target-rate mode picks 0-3 generations per game frame. While it is warm the input ring and shared chain follow every frame, so frames that generate nothing don't leave the next one without history. * Credit LSFG: Camille LaVey / Eden, ported to WinNative and DroidDeck by @maxjivi05 * LSFG: aim 0.1 fps under the target so output never passes the refresh rate --- README.md | 2 +- app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_common.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_common.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp | 36 +--- app/src/main/cpp/framegen/lsfg/lsfg_dll.h | 15 +- app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_engine.cpp | 9 +- app/src/main/cpp/framegen/lsfg/lsfg_engine.h | 9 +- app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_generate.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_generate.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp | 16 -- .../main/cpp/framegen/lsfg/lsfg_mipmaps.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_mipmaps.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp | 10 +- app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_shaders.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_shaders.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_vkd.h | 4 +- .../cpp/waylandcomp/src/framegen_bridge.c | 51 +++-- .../cpp/waylandcomp/src/framegen_bridge.h | 7 +- .../cpp/waylandcomp/src/framegen_engine.cpp | 6 +- .../cpp/waylandcomp/src/framegen_engine.h | 5 +- .../waylandcomp/src/framegen_engine_stub.c | 5 +- .../main/cpp/waylandcomp/src/framegen_jni.c | 4 +- .../com/droiddeck/launcher/MainActivity.kt | 38 +++- .../com/droiddeck/launcher/SessionActivity.kt | 56 ++++-- .../com/droiddeck/launcher/gpu/FrameGen.kt | 84 ++++---- .../com/droiddeck/launcher/gpu/Lossless.kt | 187 ++++++++++++++++++ .../droiddeck/launcher/gpu/LsfgNative.java | 105 ++-------- .../com/droiddeck/launcher/session/PerfHud.kt | 20 +- .../launcher/session/SessionService.kt | 1 + .../com/droiddeck/launcher/ui/FrameGenMenu.kt | 44 ++++- .../droiddeck/launcher/ui/FrontEndScreen.kt | 9 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 8 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 16 +- .../launcher/wayland/CompositorHost.kt | 4 +- .../launcher/wayland/WaylandCompositor.java | 8 +- app/src/main/res/values/strings.xml | 22 +++ .../droiddeck/launcher/gpu/FrameGenTest.kt | 42 ++++ .../droiddeck/launcher/gpu/LosslessTest.kt | 48 +++++ .../overlay/usr/local/bin/bannerlator-session | 18 ++ 51 files changed, 676 insertions(+), 353 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt diff --git a/README.md b/README.md index dd0abc25f..ea1242bb2 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,4 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp index 73be35b59..04a37b720 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_alpha.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp index 6c233c16c..9a4e15ddf 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp index 299e22cf7..d9413e647 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_beta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp index 1d1e77a56..7d259f9e2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp index f9ff4b4f8..54acb1a0f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_chain.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp index 4324c80bc..4bab527fd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp index b72eb2287..c93275486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_common.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp index 1f6b20983..c5ab3e0ad 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp index ddba102d5..cf9b31bfa 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_delta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp index 8d8232675..e40ffe7bd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp index 78ddbccc7..0af479be8 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp @@ -1,9 +1,9 @@ // See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless // Scaling frame-generation chain. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to -// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk -// (PancakeTAS). Bannerlator is GPL-3.0. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. #include "lsfg_dll.h" #include "lsfg_dxbc.h" @@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) { return status; } -Variant dllVariant(const std::string& dllPath, bool preferFp16) { - if (dllPath.empty()) return Variant::None; - - PeImage img; - if (!img.open(dllPath)) return Variant::None; - - ResourceTable table; - if (parseResources(img, table) != DllStatus::Ok) return Variant::None; - - Variant v = selectVariant(table, preferFp16); - if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated; - return v; -} - DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) { if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled; @@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b return DllStatus::Ok; } -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches) { - outMatches = false; - if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable; - - CacheHeader header{}; - if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable; - - PeImage img; - if (!img.open(dllPath)) return DllStatus::NotInstalled; - - outMatches = header.sourceSize == (uint64_t)img.size() - && header.sourceHash == fnv1a64(img.data(), img.size()); - return DllStatus::Ok; -} - DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) { outVariant = Variant::None; CacheHeader header{}; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h index 72a862b58..74d6ca83d 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h @@ -7,7 +7,7 @@ // ship as RCDATA resources inside Lossless.dll from the user's own paid copy // of Lossless Scaling (Steam app 993090). Every implementation - upstream // lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their -// own DLL and extracts the resources on device. Bannerlator does the same, and +// own DLL and extracts the resources on device. DroidDeck does the same, and // bundles nothing. // // The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a @@ -26,9 +26,9 @@ // Both producers fill the same shader-id -> SPIR-V words map, so everything // downstream of this file is source-agnostic. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is -// credited to Camille LaVey / the Eden Emulator Project, and which follows -// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. // ============================================================================ #include @@ -98,18 +98,11 @@ const char* variantName(Variant v); // reported as valid - the translator handles it. DllStatus validateDll(const std::string& dllPath); -// Which producer would be used for this DLL. -Variant dllVariant(const std::string& dllPath, bool preferFp16); - // Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath // (via temp file + rename, so a failed build cannot leave a half-written // cache behind). Records which producer ran in the cache header. DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16); -// Is the cache current for this DLL? Compares source size + content hash. -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches); - // Load a previously built cache. DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet); diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp index db23e3686..c8084119f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp @@ -1,9 +1,10 @@ // See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator. // // Follows upstream lsfg-vk's src/extract/trans.cpp, including its -// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp -// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored -// under cpp/thirdparty/dxbc. +// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and +// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator +// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, +// vendored under cpp/thirdparty/dxbc. #include "lsfg_dxbc.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp index 4f1482eb7..ba9e8a021 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp @@ -1,7 +1,8 @@ // See lsfg_engine.h. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. #include "lsfg_engine.h" @@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // nothing. Copy only when generating, on the one priming frame that // precedes generation, or while the governor is warming up towards a // probe - so the ring is populated by the time it is needed. - const bool needHistory = generations > 0 || primeHistory_ + const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame() || (governorEnabled_ && governor_.wantsHistory()); if (needHistory) { copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height}); @@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // regardless of the game's own settings, and it fed straight back into the // governor: the chain made the source rate collapse, the governor saw the // collapse and refused to generate, and refusing did not stop the chain. - if (warm_ && generations > 0) chain_->DispatchShared(cmd, count); + if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count); } void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex, diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h index 20489e652..c8a5028d0 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h @@ -16,8 +16,9 @@ // unavoidable cost of interpolating rather than extrapolating; no placement of // this code changes it. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. // ============================================================================ #include @@ -109,6 +110,10 @@ private: // generation last stopped. bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; } + // With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the + // shared chain have to follow every frame it keeps warm, not just the ones that generate. + bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; } + Device device_{}; std::string cachePath_; std::unique_ptr shaders_; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp index 392930b61..574382ed7 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_gamma.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp index 8210dd259..d1cd38486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp index 1c84cb186..7be96561c 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_generate.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp index 68e23967b..0612b7422 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp index ba8447313..ab13096d6 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp @@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass return (jint)lsfg::validateDll(toStdString(env, dllPath)); } -extern "C" JNIEXPORT jint JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath, - jboolean preferFp16) { - return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath, jstring cachePath, jboolean preferFp16) { @@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, preferFp16 == JNI_TRUE); } -extern "C" JNIEXPORT jboolean JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass, - jstring cachePath, - jstring dllPath) { - bool matches = false; - const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath), - toStdString(env, dllPath), matches); - return (jboolean)(status == lsfg::DllStatus::Ok && matches); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) { lsfg::Variant variant = lsfg::Variant::None; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp index 4d1762555..ef1e11318 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_mipmaps.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp index 4ce06b0f5..d301c26ef 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp index 29da8729d..8ddf453c2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_pacer.hpp" @@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f; constexpr float SOURCE_STALE_SECONDS = 0.5f; constexpr float DISCONTINUITY_SECONDS = 0.25f; constexpr float HEADROOM_EPSILON = 0.02f; +// A target is aimed just under, so rounding never puts output past the panel's refresh rate. +constexpr float TARGET_MARGIN = 0.1f; constexpr float CREDIT_EPSILON = 1.0e-4f; constexpr float SOURCE_ACCUM_FLOOR = 0.01f; constexpr uint32_t MIN_RATE_SAMPLES = 12; @@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) { if (target_rate > 0.0f && config.refresh_rate > 0.0f) { target_rate = std::min(target_rate, config.refresh_rate); } + if (target_rate > 0.0f) target_rate -= TARGET_MARGIN; if (target_rate == 0.0f) { // No explicit target rate: the multiplier is the multiplier, full stop. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp index 5613c4c30..fe4a43ee5 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp index 96fb7bfdc..d3266d173 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_shaders.hpp" #include "lsfg_common.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp index ba7854da4..d91b00c18 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h index 0657ac975..c86d2224f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h @@ -2,10 +2,10 @@ // ============================================================================ // lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global. // -// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan +// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan // through a per-context VkTable (`vk_`), because an adrenotools-loaded driver // lives in an isolated linker namespace and shares no global symbols with the -// system loader. The LSFG chain, ported from eden by way of WinNative, calls +// system loader. The LSFG chain, ported from Eden by way of WinNative, calls // through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain // code to thread a table through every call, this shim exposes exactly the 34 // entry points the chain uses and is filled from the renderer's own table when diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 43b94987f..47a819bfd 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri static _Atomic int g_kind = VKP_FG_ENGINE_LSFG; static _Atomic int g_armed; static _Atomic int g_mult = 2; +static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */ static _Atomic int g_model = 3; static _Atomic int g_preset = 2; static _Atomic int g_cfg_dirty = 1; @@ -51,6 +52,7 @@ static struct { /* ---- engine + generation ring (compositor thread) ------------------------------------ */ static int g_engine_kind = -1; /* kind of the running engine, -1 = none */ +static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */ static int g_engine_ok; /* The engine of this kind (with this cache) could not start or build its chain; not retried * every frame - a new engine selection or a new cache clears it. */ @@ -92,15 +94,20 @@ static int64_t now_ns(void) { void vkp_framegen_set_engine(int kind) { if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG; atomic_store(&g_kind, kind); + atomic_store(&g_failed_kind, -1); atomic_store(&g_cfg_dirty, 1); } -void vkp_framegen_set_armed(int armed, int multiplier) { +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) { + if (target_fps < 0) target_fps = 0; + /* Adaptive sizes the ring and the swapchain for the most it may ever generate. */ + if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (multiplier < 2) multiplier = 2; if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1); + if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1); atomic_store(&g_armed, armed ? 1 : 0); - LOGD("framegen: set_armed(%d, x%d)", armed, multiplier); + LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps); } void vkp_framegen_set_lsfg_cache_path(const char *path) { @@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) { } static int ensure_engine(int kind) { - if (g_engine_ok && g_engine_kind == kind) return 1; + const int cache_gen = atomic_load(&g_cache_gen); + if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen)) + return 1; if (start_failed_for(kind)) return 0; if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; } char *path = NULL; @@ -293,7 +302,7 @@ static int ensure_engine(int kind) { if (g_cache_path) path = strdup(g_cache_path); pthread_mutex_unlock(&g_lock); if (kind == VKP_FG_ENGINE_LSFG && !path) { - FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)"); + FGLOG("LSFG Native can't start: no shader cache from Lossless.dll"); mark_failed(kind); return 0; } @@ -305,29 +314,39 @@ static int ensure_engine(int kind) { mark_failed(kind); return 0; } - g_engine_ok = 1; g_engine_kind = kind; + g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen; atomic_store(&g_cfg_dirty, 1); g_built_w = g_built_h = 0; FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info()); return 1; } -static int g_logged_mult; +/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */ +static const char *describe_mode(char *buf, size_t n, int mult, int target) { + if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1); + else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s"); + return buf; +} + +static int g_logged_mult, g_logged_target; static void log_arm_transition(int armed, int kind, int mult) { + const int target = atomic_load(&g_target); + char mode[96]; if (armed == g_was_armed) { - if (armed && mult != g_logged_mult) { - g_logged_mult = mult; - FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1); + if (armed && (mult != g_logged_mult || target != g_logged_target)) { + g_logged_mult = mult; g_logged_target = target; + FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target)); } return; } g_was_armed = armed; if (armed) { - g_logged_mult = mult; + g_logged_mult = mult; g_logged_target = target; float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " - "with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz); + FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " + "with the interpolated frames ahead of them", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz); } else { FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated); g_generating_logged = 0; @@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, if (atomic_exchange(&g_cfg_dirty, 0)) { float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset)); + fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz, + atomic_load(&g_model), atomic_load(&g_preset)); } if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) { if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) { @@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, 0, 0, NULL, 0, NULL, nb, bars); if (!g_generating_logged) { g_generating_logged = 1; - FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)", - fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s", + char mode[96]; + FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h, fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : ""); } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index 2d4d6d99a..b60229cdb 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 }; /* ---- app controls (any thread) ------------------------------------------------------- */ void vkp_framegen_set_engine(int kind); -/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */ -void vkp_framegen_set_armed(int armed, int multiplier); -/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */ +/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs); + * armed = 0 stops generating (the ring and the engine stay for a re-arm). */ +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps); +/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */ void vkp_framegen_set_lsfg_cache_path(const char *path); /* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 1d9d29468..35e160edc 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -197,11 +197,11 @@ int fge_unavailable(void) { return g_lsfg && g_lsfg->unavailable() ? 1 : 0; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset) { +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset) { if (multiplier < 2) multiplier = 2; if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale); - else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz); + else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz); } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.h b/app/src/main/cpp/waylandcomp/src/framegen_engine.h index ecc9e30d1..b0deec70b 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.h @@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path); void fge_stop(void); /* The engine came up but later found it cannot generate (chain build failed). */ int fge_unavailable(void); -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset); +/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */ +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset); /* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same * (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */ int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index 45bd27dff..dd4144d3e 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; } int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; } void fge_stop(void) {} int fge_unavailable(void) { return 1; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) { - (void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model, + int perf_preset) { + (void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; } uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c index c693ad25c..8d5bad571 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c @@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz, - jboolean armed, jint multiplier) { + jboolean armed, jint multiplier, jint targetFps) { (void)env; (void)clazz; - vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier); + vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps); } JNIEXPORT void JNICALL diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ae962bb76..a26d2097b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.toArgb import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.TurnipDriver -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller @@ -101,6 +101,7 @@ class MainActivity : ComponentActivity() { private var percent by mutableIntStateOf(-1) private var failed by mutableStateOf(false) private var frameGenLabel by mutableStateOf("Off") + private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) private var showNonAdreno by mutableStateOf(null) private var glThread by mutableStateOf(true) @@ -158,6 +159,9 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } + private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) @@ -167,6 +171,26 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) } + /** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + ui.post { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + ui.post { + lossless = state + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + } + }, "lossless-import").start() + } + /** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */ private fun importSaves(name: String, game: () -> GameSaves.Game) { if (SessionState.running) { @@ -337,8 +361,8 @@ class MainActivity : ComponentActivity() { offlineAccount = offlineAccount, offline = offline, frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, steamGames = steamGames, emulators = emulatorList, running = runningLabel, - frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), - lsfgReady = LsfgNative.isInstalled(this), + frameGen = FrameGen.mode(this), + lossless = lossless, pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, @@ -414,10 +438,13 @@ class MainActivity : ComponentActivity() { onInstallPackage = { id -> installPackage(id) }, onRemovePackage = { id -> removePackage(id) }, onRuntime = { onRuntimeButton() }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this, mode) frameGenLabel = FrameGen.label(this) }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. @@ -649,6 +676,7 @@ class MainActivity : ComponentActivity() { returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn()) } refreshPhantomStatus() + syncLossless() // Opening the app and coming back from a session both land here. updates.onResume() // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 1c58332a7..9db297df2 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -24,6 +24,7 @@ import android.widget.FrameLayout import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -33,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.KeyboardHost @@ -170,9 +172,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) private var fillScreen by mutableStateOf(true) - private var frameGenLabel by mutableStateOf("Off") - private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF) - private var frameGenMultiplier by mutableStateOf(2) + private var lossless by mutableStateOf(Lossless.State.NONE) + private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } + private var frameGen by mutableStateOf(FrameGen.Mode.OFF) private var fexPreset by mutableStateOf("") private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) @@ -338,6 +342,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Opening the drawer takes the controller away from the game: release its pad. androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { + syncLossless() padBridge?.releaseAll() androidx.compose.runtime.withFrameNanos { } val requested = sessionOverlay.requestFocus() @@ -361,8 +366,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { androidApps = androidApps, hudOn = hudOn, fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, - frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, - lsfgReady = LsfgNative.isInstalled(this@SessionActivity), + frameGen = frameGen, + lossless = lossless, oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, touchAuto = if (usingTouchpad()) "touchpad" else "direct", shapeMode = shapeMode, fexPreset = fexPreset, @@ -371,11 +376,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this@SessionActivity, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this@SessionActivity, mode) readPrefs() applyFrameGen() }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent( + this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, @@ -581,9 +590,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { hudOn = SessionPrefs.hudEnabled(this) fillScreen = SessionPrefs.forceFullscreen(this) touchMode = SessionPrefs.touchMode(this) - frameGenLabel = FrameGen.label(this) - frameGenEngine = FrameGen.engine(this) - frameGenMultiplier = FrameGen.multiplier(this) + frameGen = FrameGen.mode(this) fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) oscMode = SessionPrefs.oscMode(this) @@ -887,10 +894,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val hz = refreshHz() Thread({ val problem = FrameGen.apply(this, hz) - if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() } + val state = Lossless.state(this) + runOnUiThread { + lossless = state + if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show() + } }, "frame-gen").start() } + /** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + runOnUiThread { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + runOnUiThread { + lossless = state + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen() + } + }, "lossless-import").start() + } + // ── Session state ─────────────────────────────────────────────────────────────────────── /** Keeps the loading overlay current from the session log, and its clock moving. */ diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 236c17a00..b864e837b 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu import android.content.Context import android.util.Log +import com.droiddeck.launcher.R import com.droiddeck.launcher.wayland.WaylandCompositor /** @@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor * * Both engines generate on gamescope's final output inside our compositor, so they work for any * game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the - * shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor + * shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor * setter is a value store on the native side, so this can be applied before the compositor is * up and changed live from the main screen while a session runs. */ @@ -26,62 +27,77 @@ object FrameGen { private const val FLOW_SCALE_WINFG = 0.60f private const val FLOW_SCALE_LSFG = 0.80f + /** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */ + val ADAPTIVE_TARGETS = listOf(120, 90, 60) + private const val PREFS = "frame_gen" - fun engine(context: Context): String = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF - - fun multiplier(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4) - - fun set(context: Context, engine: String, multiplier: Int) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() - .putString("engine", engine).putInt("multiplier", multiplier).apply() + /** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */ + data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) { + companion object { + val OFF = Mode(ENGINE_OFF) + } } - /** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */ - fun label(context: Context): String = label(engine(context), multiplier(context)) + fun engine(context: Context): String = mode(context).engine - fun label(engine: String, multiplier: Int): String = when (engine) { - ENGINE_WINFG -> "Win-FG $multiplier×" - ENGINE_LSFG -> "LSFG $multiplier×" - else -> "Off" + fun mode(context: Context): Mode { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF + val target = prefs.getInt("target", 0) + if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target) + return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4)) + } + + fun set(context: Context, mode: Mode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply() + } + + /** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */ + fun label(context: Context): String = label(context, mode(context)) + + fun label(context: Context, mode: Mode): String = when { + mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier) + mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target) + mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier) + else -> context.getString(R.string.frame_gen_off) } /** * Pushes the saved setting into the compositor. Returns a message for the user when the - * engine cannot run (LSFG without Lossless Scaling), or null. + * engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated + * Lossless.dll is prepared. */ fun apply(context: Context, refreshHz: Float): String? { - val engine = engine(context) - val multiplier = multiplier(context) - var problem: String? = null - when (engine) { + val mode = mode(context) + when (mode.engine) { ENGINE_LSFG -> { - val status = LsfgNative.ensureCache(context) - if (status != LsfgNative.STATUS_OK) { - problem = if (status == LsfgNative.STATUS_NOT_INSTALLED) - "LSFG needs Lossless Scaling installed from the Steam client" - else "LSFG: " + LsfgNative.statusName(status) - Log.w(TAG, problem) - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + val status = Lossless.sync(context) + val cache = Lossless.cacheFile(context) + if (status != LsfgNative.STATUS_OK || cache == null) { + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + val problem = context.getString( + if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed, + ) + Log.w(TAG, "$problem (status $status)") return problem } WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG) - WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath) + WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target) } ENGINE_WINFG -> { WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG) WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0) } - else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0) + else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) } - Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz") - return problem + Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz") + return null } private const val TAG = "FrameGen" diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt new file mode 100644 index 000000000..7a153b37e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt @@ -0,0 +1,187 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import java.security.DigestOutputStream +import java.security.MessageDigest + +/** + * LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090). + * + * The copy the Steam client installs, in any library it installs to, is taken by + * itself and taken again whenever the client updates it. Once the session has seen the account + * own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes + * or another is imported. The DLL is copied in and its shaders are built into a cache named after + * its contents, so a different DLL hands the compositor a different path and its engine reloads. + * Everything here blocks: call it off the main thread. + */ +object Lossless { + enum class Source { NONE, STEAM, IMPORT } + + enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED } + + data class State(val owned: Boolean, val ready: Boolean, val source: Source) { + companion object { + val NONE = State(owned = false, ready = false, source = Source.NONE) + } + } + + private const val TAG = "Lossless" + private const val PREFS = "lossless" + private const val KEY_OWNED = "owned" + private const val KEY_ID = "id" + private const val KEY_SOURCE = "source" + private const val KEY_STEAM_STAMP = "steamStamp" + private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll" + private const val UNCHANGED = -1 + + fun state(context: Context): State { + val source = prefs(context).getString(KEY_SOURCE, null) + ?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE + return State(owned(context), cacheFile(context)?.isFile == true, source) + } + + /** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */ + fun owned(context: Context): Boolean { + if (prefs(context).getBoolean(KEY_OWNED, false)) return true + if (!ownedFlag(context).exists()) return false + markOwned(context) + return true + } + + fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) } + + /** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */ + @Synchronized + fun sync(context: Context): Int { + val steam = steamDll(context) + if (steam != null) { + markOwned(context) + val stamp = "${steam.length()}:${steam.lastModified()}" + if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) { + val status = adopt(context, steam, Source.STEAM) + if (status != LsfgNative.STATUS_OK && status != UNCHANGED) { + Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}") + } + prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply() + } + } + return ensureBuilt(context) + } + + @Synchronized + fun import(context: Context, dll: File): ImportResult { + if (!owned(context)) return ImportResult.NOT_OWNED + return when (adopt(context, dll, Source.IMPORT)) { + UNCHANGED -> ImportResult.UNCHANGED + LsfgNative.STATUS_OK -> ImportResult.UPDATED + LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE, + LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID + else -> ImportResult.FAILED + } + } + + fun message(context: Context, result: ImportResult): String = context.getString( + when (result) { + ImportResult.UPDATED -> R.string.lsfg_imported + ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged + ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned + ImportResult.INVALID -> R.string.lsfg_import_invalid + ImportResult.FAILED -> R.string.lsfg_import_failed + }, + ) + + private fun ensureBuilt(context: Context): Int { + val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED + val cache = cacheFor(context, id) + if (usable(cache)) return LsfgNative.STATUS_OK + val dll = storedDll(context) + if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED + return build(dll, cache) + } + + private fun adopt(context: Context, source: File, from: Source): Int { + val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged") + try { + val id = copyHashing(source, staged) + if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED + val cache = cacheFor(context, id) + var status = LsfgNative.nativeValidateDll(staged.path) + if (status == LsfgNative.STATUS_OK) status = build(staged, cache) + if (status != LsfgNative.STATUS_OK) { + cache.delete() + return status + } + if (!staged.renameTo(storedDll(context))) { + cache.delete() + return LsfgNative.STATUS_CACHE_UNUSABLE + } + prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply() + removeStale(context, id) + Log.i(TAG, "Lossless.dll from $source ($from) ready as $id") + return LsfgNative.STATUS_OK + } catch (e: java.io.IOException) { + Log.w(TAG, "could not copy $source", e) + return LsfgNative.STATUS_UNREADABLE_FILE + } catch (t: Throwable) { + Log.e(TAG, "could not take $source", t) + return LsfgNative.STATUS_CACHE_UNUSABLE + } finally { + staged.delete() + } + } + + private fun build(dll: File, cache: File): Int = try { + val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true) + Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" + + if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "") + status + } catch (t: Throwable) { + Log.e(TAG, "shader cache ${cache.name}", t) + LsfgNative.STATUS_CACHE_UNUSABLE + } + + private fun usable(cache: File): Boolean = cache.isFile && + runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false) + + private fun copyHashing(source: File, target: File): String { + val digest = MessageDigest.getInstance("SHA-256") + source.inputStream().use { input -> + DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) } + } + return digest.digest().take(8).joinToString("") { "%02x".format(it) } + } + + private fun removeStale(context: Context, keep: String) { + File(context.filesDir, "lsfg-native").deleteRecursively() + dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name } + ?.forEach { it.delete() } + } + + /** The newest copy in any library the client installs to: its own, the card's, the Games folders. */ + private fun steamDll(context: Context): File? { + val libraries = listOfNotNull( + File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"), + GameStorage.effective(context)?.path?.let(::File), + ) + SessionPrefs.addedGamesDirs(context).map(::File) + return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() } + } + + private fun markOwned(context: Context) { + if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply() + } + + private fun statusName(status: Int): String = + runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status") + + private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + private fun dir(context: Context) = File(context.filesDir, "lsfg") + private fun storedDll(context: Context) = File(dir(context), "Lossless.dll") + private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache") + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java index c9b03ef03..29c7dbd17 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java @@ -1,99 +1,30 @@ package com.droiddeck.launcher.gpu; -import android.content.Context; -import android.util.Log; - -import com.droiddeck.launcher.runtime.LinuxRuntime; - -import java.io.File; - /** - * LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling - * and cached on device. - * - *

The 25 compute shaders are not redistributable. They live as resources inside the user's - * paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the - * Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped - * read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth - * doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the - * DLL's size and hash. Call {@link #ensureCache} off the main thread. - * - *

Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into - * the compositor library. + * The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library): + * {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its + * DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block. */ -public final class LsfgNative { - private static final String TAG = "LsfgNative"; - +final class LsfgNative { static { System.loadLibrary("bannerwayland"); } private LsfgNative() {} // Mirrors lsfg::DllStatus. - public static final int STATUS_OK = 0; - public static final int STATUS_NOT_INSTALLED = 1; - public static final int STATUS_UNREADABLE_FILE = 2; - public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; - public static final int STATUS_MISSING_SHADERS = 4; - public static final int STATUS_TRANSLATION_FAILED = 5; - public static final int STATUS_CACHE_UNUSABLE = 6; + static final int STATUS_OK = 0; + static final int STATUS_NOT_INSTALLED = 1; + static final int STATUS_UNREADABLE_FILE = 2; + static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; + static final int STATUS_MISSING_SHADERS = 4; + static final int STATUS_TRANSLATION_FAILED = 5; + static final int STATUS_CACHE_UNUSABLE = 6; - private static native int nativeValidateDll(String dllPath); - private static native int nativeDllVariant(String dllPath, boolean preferFp16); - private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); - private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath); - private static native int nativeCacheVariant(String cachePath); - private static native String nativeStatusName(int status); - private static native String nativeVariantName(int variant); + // Mirrors lsfg::Variant. + static final int VARIANT_NONE = 0; - /** Where Lossless Scaling lands when installed from the client inside the runtime. */ - public static File losslessDll(Context context) { - return new File(LinuxRuntime.rootDir(context), - "root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"); - } - - /** Where the translated SPIR-V chain is cached. */ - public static File cacheFile(Context context) { - return new File(context.getFilesDir(), "lsfg-native/shaders.cache"); - } - - public static boolean isInstalled(Context context) { - return losslessDll(context).isFile(); - } - - /** - * Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or - * changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start. - */ - public static int ensureCache(Context context) { - File dll = losslessDll(context); - if (!dll.isFile()) return STATUS_NOT_INSTALLED; - File cache = cacheFile(context); - try { - if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) { - return STATUS_OK; - } - int status = nativeValidateDll(dll.getPath()); - if (status != STATUS_OK) { - Log.w(TAG, "Lossless.dll rejected: " + statusName(status)); - return status; - } - //noinspection ResultOfMethodCallIgnored - cache.getParentFile().mkdirs(); - status = nativeBuildCache(dll.getPath(), cache.getPath(), true); - Log.i(TAG, "cache build: " + statusName(status) - + (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : "")); - return status; - } catch (Throwable t) { - Log.e(TAG, "ensureCache", t); - return STATUS_CACHE_UNUSABLE; - } - } - - public static String statusName(int status) { - try { - return nativeStatusName(status); - } catch (Throwable t) { - return "status " + status; - } - } + static native int nativeValidateDll(String dllPath); + static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); + static native int nativeCacheVariant(String cachePath); + static native String nativeStatusName(int status); + static native String nativeVariantName(int variant); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index b0d49257b..a9b5b0459 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -8,7 +8,9 @@ import android.os.SystemClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.util.Locale @@ -86,20 +88,22 @@ class PerfHud(context: Context) { } private fun line(base: Float): String { - val engine = FrameGen.engine(context) - if (engine == FrameGen.ENGINE_OFF) return fps(base) - val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG" - val multiplier = FrameGen.multiplier(context) + val mode = FrameGen.mode(context) + if (mode.engine == FrameGen.ENGINE_OFF) return fps(base) + val lsfg = mode.engine == FrameGen.ENGINE_LSFG + val name = if (lsfg) "LSFG" else "Win-FG" + val label = FrameGen.label(context, mode) + if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name) val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null } val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 } return when { - problem == 1 -> "$name: unsupported" - problem == 2 -> "$name: engine failed" + problem == 1 -> context.getString(R.string.hud_fg_unsupported, name) + problem == 2 -> context.getString(R.string.hud_fg_failed, name) stats != null && stats[3] > 1f -> { val source = if (stats[2] > 1f) stats[2] else base - String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3]) + String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3]) } - else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base)) + else -> context.getString(R.string.hud_fg_starting, label, fps(base)) } } 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 27dc9ac5f..9a5465bd6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -314,6 +314,7 @@ class SessionService : Service() { val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) guest.add("BL_ADDED_GAMES=" + listing.path) if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1") + if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1") if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) } // Where the guest leaves a request for another session (the desktop's Steam launchers). diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt index 2d533fd52..1ec0fbfad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -1,22 +1,48 @@ package com.droiddeck.launcher.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless /** * The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup, - * a game's launch settings and the session drawer, so they say the same thing. + * a game's launch settings and the session drawer, so they say the same thing. Importing a + * Lossless.dll is offered once the account is known to own Lossless Scaling. */ @Composable internal fun FrameGenMenu( - host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean, - onPick: (engine: String, multiplier: Int) -> Unit, + host: MenuHost, mode: FrameGen.Mode, lossless: Lossless.State, + onPick: (FrameGen.Mode) -> Unit, + onImportLossless: () -> Unit, ) { - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam" - fun pick(e: String, m: Int) { onPick(e, m); host.open = null } - MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) } + val context = LocalContext.current + AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = stringResource(R.string.frame_gen_title)) { firstItemFocus -> + val need = when { + lossless.ready -> null + lossless.owned -> stringResource(R.string.lsfg_needs_install) + else -> stringResource(R.string.lsfg_needs_steam) + } + @Composable + fun item(m: FrameGen.Mode, enabled: Boolean = true, detail: String? = null, focus: FocusRequester? = null) = + MenuItem(FrameGen.label(context, m), checked = mode == m, enabled = enabled, detail = detail, focusRequester = focus) { + onPick(m); host.open = null + } + item(FrameGen.Mode.OFF, focus = firstItemFocus) + for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m)) + val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } + + FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) } + lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) } + if (lossless.owned) { + val using = when (lossless.source) { + Lossless.Source.STEAM -> stringResource(R.string.lsfg_using_steam) + Lossless.Source.IMPORT -> stringResource(R.string.lsfg_using_import) + Lossless.Source.NONE -> null + } + MenuItem(stringResource(R.string.lsfg_import), checked = false, detail = using) { host.open = null; onImportLossless() } + } } } 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 1c0619fa3..74f5caaf3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -91,9 +92,8 @@ class FrontEndState( val steamGames: List, val emulators: List, val running: String?, - val frameGenEngine: String = FrameGen.ENGINE_OFF, - val frameGenMultiplier: Int = 2, - val lsfgReady: Boolean = false, + val frameGen: FrameGen.Mode = FrameGen.Mode.OFF, + val lossless: Lossless.State = Lossless.State.NONE, val pageKey: String? = null, val theme: String = Themes.GRAPHITE, val isHomeApp: Boolean = false, @@ -139,7 +139,8 @@ class FrontEndActions( val onInstallPackage: (String) -> Unit, val onRemovePackage: (String) -> Unit, val onRuntime: () -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, 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 bc51f4036..8a3dee136 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInputModeManager @@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.R // The Setup page: runtime and device checks, tools, frame generation and launch settings. @@ -269,7 +271,7 @@ internal fun SetupPanel( true to SessionPrefs.BACK_QAM_THEN_MENU, ), s.backActionsInverted, onPick = a.onBackActionsInverted, ) - SettingsRow("Frame generation", "Select the frame generation mode") { + SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) { Box { ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) @@ -383,7 +385,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { @Composable private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless) } /** @@ -399,7 +401,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } } add { m -> Box(m) { - SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { + SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 501dd69b9..af38940f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -113,9 +113,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalContext import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FexPreset import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.input.SecondScreenDisplay @@ -157,9 +159,8 @@ class DrawerActions( val isHomeApp: Boolean, val androidApps: List, val hudOn: Boolean, - val frameGenEngine: String, - val frameGenMultiplier: Int, - val lsfgReady: Boolean, + val frameGen: FrameGen.Mode, + val lossless: Lossless.State, val oscMode: String, val suspendPolicy: String, val backActionsInverted: Boolean, @@ -173,7 +174,8 @@ class DrawerActions( val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, /** The Android keyboard (text, turned into key presses). */ val onKeyboard: () -> Unit, /** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */ @@ -345,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen, ) val fgOpen = host.open == "fg" - val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier) - SettingsRow("Frame generation", null, highlighted = fgOpen) { + val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) + SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { Box { ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" } - FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless) } } } 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 973332542..8e9fd35c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -69,7 +69,7 @@ object CompositorHost { // came back stretched with an engine on and right with it off. Disarmed, the window is // rebound and the swapchain rebuilt; the caller re-arms once that has settled, and every // engine-side object is made again for the new size. - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "surface resized: rebinding the window, frame generation re-armed after") WaylandCompositor.nativeSetSurface(null) attached = surface @@ -85,7 +85,7 @@ object CompositorHost { */ fun rearmFrameGen(rearm: () -> Unit) { if (!started) return - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "presenting window changed: frame generation re-armed") android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(rearm, 600) } 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 048ff2ef1..9b7fda731 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -450,10 +450,12 @@ public final class WaylandCompositor { public static native void nativeSetFrameGenEngine(int kind); /** Arm (multiplier 2..4: one real frame plus multiplier-1 interpolated ones per game frame) or - * disarm. Generated frames are presented ahead of the real frame on consecutive vblanks. */ - public static native void nativeSetFrameGenArmed(boolean armed, int multiplier); + * disarm. A targetFps above 0 makes LSFG adaptive instead: it generates 0..3 frames per game + * frame, whatever reaches that rate. Generated frames are presented ahead of the real frame + * on consecutive vblanks. */ + public static native void nativeSetFrameGenArmed(boolean armed, int multiplier, int targetFps); - /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code LsfgNative.cacheFile}). */ + /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code Lossless.cacheFile}). */ public static native void nativeSetLsfgCachePath(String path); /** Flow scale (0.25-1.0) and the panel's real refresh rate (the pacer never generates above it). */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10cf334af..cfa4435bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,28 @@ Resume Stop session Frame generation + Select the frame generation mode + Off + Win-FG %1$d× + LSFG %1$d× + LSFG Adaptive %1$d + Needs Lossless Scaling on Steam + Install Lossless Scaling in Steam or import Lossless.dll + Import Lossless.dll… + Using the copy from Steam + Using an imported copy + Choose Lossless.dll + Lossless.dll imported + This Lossless.dll is already in use + Lossless Scaling is not owned on this Steam account + This file is not Lossless Scaling\'s Lossless.dll + Could not prepare LSFG from this Lossless.dll + LSFG needs Lossless Scaling from Steam or an imported Lossless.dll + LSFG could not prepare its shaders from Lossless.dll + %1$s: unsupported + %1$s: engine failed + %1$s: needs Lossless Scaling + %1$s %2$s · starting Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games. Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts. Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk. diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt new file mode 100644 index 000000000..fe3c9773d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class FrameGenTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun offUntilPicked() { + assertEquals(FrameGen.Mode.OFF, FrameGen.mode(context)) + } + + @Test fun adaptiveTargetIsKept() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90), FrameGen.mode(context)) + } + + @Test fun fixedMultiplierReplacesATarget() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 120)) + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, 3)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, 3), FrameGen.mode(context)) + } + + @Test fun onlyLsfgIsAdaptive() { + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() + .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt new file mode 100644 index 000000000..158351477 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LosslessTest { + private lateinit var context: Context + private lateinit var flag: File + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("lossless", Context.MODE_PRIVATE).edit().clear().commit() + flag = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + flag.delete() + } + + @Test fun nothingIsOwnedOrReadyUntilTheSessionSaysSo() { + assertEquals(Lossless.State.NONE, Lossless.state(context)) + assertNull(Lossless.cacheFile(context)) + } + + @Test fun theSessionsOwnershipFlagIsRememberedForGood() { + flag.parentFile!!.mkdirs() + flag.writeText("") + assertTrue(Lossless.owned(context)) + flag.delete() + assertTrue(Lossless.owned(context)) + } + + @Test fun importIsRefusedWithoutOwnership() { + val dll = File(context.cacheDir, "Lossless.dll").apply { writeText("not a dll") } + assertFalse(Lossless.owned(context)) + assertEquals(Lossless.ImportResult.NOT_OWNED, Lossless.import(context, dll)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index d579dc73b..39370ce73 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -578,6 +578,24 @@ BUSCONF bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & games_watcher=$! fi + # Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in + # client once a session until the answer is yes. From then on the app offers importing + # Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again. + if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then + ( + owned=$HOME/.config/droiddeck/lossless-owned + for ((_i = 0; _i < 60; _i++)); do + nap 30 + [ -e "$owned" ] && exit 0 + answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue + case $answer in + *'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;; + *) echo "== Lossless Scaling: not owned by this account" ;; + esac + exit 0 + done + ) & + fi echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. From 8ee1cbe3ebe3224500c92055462a7dc2087745ed Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Wed, 30 Sep 2026 16:43:52 -0400 Subject: [PATCH 3/3] Additional performance fixes (#87) * perf: answer Turnip's zero-timeout KGSL timestamp polls without blocking Turnip polls a timeline point with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0, which KGSL reads as wait forever; Mesa does it under a lock on every signalling submit, so vkd3d-proton, DXVK, gamescope and Zink ran in lock-step with the GPU. The session preload now answers those polls from the context's retired timestamp and passes everything else to the kernel. Bannerlator measured a D3D12 demo at 614 -> 4335 fps with the same answer in its Vulkan adapter. BL_NO_KGSL_POLL_FIX=1 turns it off. * perf: frame limiter, live panel refresh, opt-in GPU clock pin, cheaper cursor sync - A per-mode frame limit (Steam and desktop settings, off by default): gamescope's -r, the compositor's buffer-release pacer, the display layer's frame-rate vote and a panel mode the cap divides evenly, as WinNative's per-shortcut limit does. BL_FPS was always 0 and the pacer's limit was never set. - The compositor follows the panel's refresh rate when it changes mid-session instead of reporting the rate read at start. (WinNative.) - Performance > GPU: hold the Adreno clock at its top through KGSL's power-control property, no root (Bannerlator's PerfGpuTurbo). Off by default; cleared when the session stops and at the next app start if a killed process left it set. - The cursor image is copied across JNI only when its serial changes, not on every pointer move. * perf: stop re-seeding every prefix every 5 s, and run the library helpers only on change - bannerlator-seed-redists stamped its change check from a block that carried the current time, so it never matched: every prefix had ~32 keys appended to system.reg on every beat. The stamp now comes from the names; Proton's prefix template is seeded too (date kept), with WinNative's built-in list until the client has the install scripts. - The library beat runs the seeder every 15 s and steam-compat only when a directory it reads has changed, or once a minute. - The client-core pin reads /proc itself and runs taskset -a only for new processes or ones that grew threads. - The games watcher backs off after a failed ownership probe and does not probe while a game runs. - The Proton launcher runs the newest ARM64 depot by build number, and PROTON_USE_PIPEWIRE defaults to 0. (WinNative.) * perf: Mesa's database shader cache, and proot's tracer above the guest - MESA_DISK_CACHE_DATABASE=1 for the session: the file cache opens a file per lookup and opens and renames one per store, each a proot stop; the database is opened once and used with pread/pwrite. - proot's single tracer thread is raised to nice -6 (under the compositor's -8) two seconds after it starts, so the first guest process it forks keeps nice 0. setpriority() directly, not Process.setThreadPriority, which would also move its cgroup. --- app/src/main/cpp/waylandcomp/src/compositor.c | 7 ++ .../cpp/waylandcomp/src/waylandcomp_jni.c | 35 ++++++++ .../main/java/com/droiddeck/launcher/App.kt | 5 +- .../com/droiddeck/launcher/MainActivity.kt | 9 +- .../com/droiddeck/launcher/SessionActivity.kt | 35 ++++++-- .../droiddeck/launcher/session/GpuClockPin.kt | 70 ++++++++++++++++ .../droiddeck/launcher/session/PerfMode.kt | 20 +++-- .../launcher/session/SessionPrefs.kt | 23 ++++++ .../launcher/session/SessionService.kt | 40 ++++++++- .../launcher/session/SessionState.kt | 4 + .../launcher/ui/ModeSettingsDialog.kt | 9 ++ .../launcher/ui/PerformanceDialog.kt | 9 ++ .../launcher/wayland/CompositorHost.kt | 19 ++++- .../launcher/wayland/WaylandCompositor.java | 11 +++ .../usr/local/bin/bannerlator-seed-redists | 82 +++++++++++++------ .../overlay/usr/local/bin/bannerlator-session | 45 ++++++++-- .../usr/local/bin/bannerlator-steam-compat | 53 ++++++++---- .../usr/local/bin/bannerlator-steam-games | 32 +++++++- tools/linuxfs/preload/kgslpoll.c | 79 ++++++++++++++++++ tools/linuxfs/preload/netif.c | 7 +- 20 files changed, 527 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt create mode 100644 tools/linuxfs/preload/kgslpoll.c diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 8b2509a68..63f0eed8a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -541,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { wl_shm_buffer_end_access(shm); } +/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below + * copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to + * change the cursor. A torn read is only a missed or an extra snapshot. */ +int banner_cursor_serial(void) { + return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED); +} + /* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. * Returns the number of ints written, or 0 if out is too small. */ int banner_cursor_snapshot(int *out, int cap) { diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 10283f570..414ca45b9 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -6,6 +6,7 @@ */ #include #include +#include #include #include #include @@ -25,6 +26,7 @@ extern int banner_wayland_run(void); extern void banner_wayland_send_pointer(int action, int x, int y); extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); extern int banner_cursor_snapshot(int *out, int cap); +extern int banner_cursor_serial(void); extern void banner_wayland_send_key(int evdev, int state); extern void banner_wayland_send_scene_input(int type, int a, int b); extern void banner_wayland_vsync(int64_t frame_time_ns); @@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( return n; } +/* The serial nativeCursorSnapshot would return, without copying anything. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + return banner_cursor_serial(); +} + /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( @@ -670,3 +679,29 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) { banner_host_text_delete(before, after); } + +/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the + * device's, not this process's: it outlives the fd and the app, so the caller clears it at stop + * and again at every app start. No root; the kernel's thermal limits still apply. */ +extern void adrenotools_set_turbo(bool turbo); +JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) { + (void)env; (void)clazz; + adrenotools_set_turbo(on == JNI_TRUE); +} + +/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering + * it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between + * scheduling groups by priority, and a guest process must stay where Android put the app. Returns + * the nice value it is left at, or 100 when it could not be read. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) { + (void)env; (void)clazz; + errno = 0; + int before = getpriority(PRIO_PROCESS, (id_t)tid); + if (before == -1 && errno != 0) return 100; + if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice); + errno = 0; + int after = getpriority(PRIO_PROCESS, (id_t)tid); + return after == -1 && errno != 0 ? 100 : after; +} diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 19e13cc88..0a563ed03 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -3,10 +3,12 @@ package com.droiddeck.launcher import android.app.Application import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler +import com.droiddeck.launcher.session.GpuClockPin /** * Process-wide setup: the crash handler, so a session's folder is finished even when we die, and - * the socket Flatpak's sandboxes are started through (BwrapSpawner). + * the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's + * sandboxes are started through (BwrapSpawner). */ class App : Application() { override fun onCreate() { @@ -14,6 +16,7 @@ class App : Application() { CrashHandler.install(this) // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) + GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index a26d2097b..c93a2b388 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -135,6 +135,7 @@ class MainActivity : ComponentActivity() { private var zinkLazy by mutableStateOf(false) private var noXalia by mutableStateOf(true) private var gamescopeRealtime by mutableStateOf(false) + private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) @@ -260,6 +261,7 @@ class MainActivity : ComponentActivity() { private var theme by mutableStateOf("graphite") private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) private var hdrOn by mutableStateOf(false) + private var fpsLimit by mutableStateOf(0) private var hdrReason by mutableStateOf(null) private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) @@ -913,7 +915,7 @@ class MainActivity : ComponentActivity() { ModeSettingsPage( ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, - hdr = hdrOn, hdrReason = hdrReason, + hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, linuxRows = drivers.linuxRows, linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop, androidRows = drivers.androidRows, androidSelected = drivers.androidSelected, @@ -949,6 +951,7 @@ class MainActivity : ComponentActivity() { onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, + onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps }, onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() }, onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) }, onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, @@ -1023,6 +1026,7 @@ class MainActivity : ComponentActivity() { clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, gamescopeRealtime = gamescopeRealtime, + gpuClockPin = gpuClockPin, prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, @@ -1031,6 +1035,7 @@ class MainActivity : ComponentActivity() { onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, + onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> @@ -1083,6 +1088,7 @@ class MainActivity : ComponentActivity() { refreshAddedGames() shapeMode = SessionPrefs.shapeMode(this) hdrOn = SessionPrefs.hdr(this, mode) + fpsLimit = SessionPrefs.fpsLimit(this, mode) hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason touchMode = SessionPrefs.touchMode(this) suspendPolicy = SessionPrefs.suspendPolicy(this, mode) @@ -1128,6 +1134,7 @@ class MainActivity : ComponentActivity() { zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) + gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9db297df2..49160f70c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -191,7 +191,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() - override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayChanged(displayId: Int) { + followPanelRefresh(displayId) + refreshSecondScreenDisplays() + } + } + + /** + * The panel's rate, followed for the rest of the session. It is read once at start, and a + * panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode + * asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and + * its games paced against a vblank that no longer came. (WinNative 53836ca9.) + */ + private fun followPanelRefresh(displayId: Int) { + if (!CompositorHost.isStarted) return + val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return + if (display.displayId != displayId) return + val hz = display.refreshRate + if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz) } private var isHomeApp by mutableStateOf(false) private var androidApps by mutableStateOf>(emptyList()) @@ -220,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) } window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - // Game-tier power policy for the whole session: the panel's fastest mode, and the OS told - // it is in gameplay. Logged so a slow device says why. - Log.i(TAG, "perf: " + PerfMode.apply(this)) + // Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap + // divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device + // says why. The cap is the session's: an activity re-created mid-session keeps it. + if (!SessionState.running) { + SessionState.fpsLimit = SessionPrefs.fpsLimit( + this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM), + ) + } + Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit)) goFullscreen() // The device's volume keys change the stream the session plays on (the relay and // PulseAudio are media playback); they are never forwarded to the guest. @@ -785,7 +808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CompositorHost.startOrAttach( holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, - applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), + applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit, ) if (!SessionState.running) SessionEvents.record("compositor.started") // The service owns everything below the compositor. It is started whenever no session is @@ -1291,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { */ private fun syncClientCursor(): Boolean { if (!CompositorHost.isStarted) return false + // Asked on every pointer move; the image is copied only when the client has changed it. + if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf) if (n < 6) return false val serial = cursorBuf[0] diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt new file mode 100644 index 000000000..8e0c7c8a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.io.File + +/** + * The GPU held at its top clock for a session, without root: KGSL's power-control property turned + * off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the + * governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch; + * Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat, + * so it is opt-in, and the kernel's thermal limits still apply on top of it. + * + * The property belongs to the device, not to this process, and a killed app cannot unwind it: it + * is cleared when a session stops, and at the next app start when a marker file says this app set + * it and never cleared it - only then, so another app's own setting is not undone. + * (Bannerlator, PerfGpuTurbo.) + */ +object GpuClockPin { + private const val TAG = "GpuClockPin" + private const val KGSL_NODE = "/dev/kgsl-3d0" + + private const val MARKER = "gpu-clock-pinned" + + @Volatile + private var pinned = false + + private val supported: Boolean by lazy { File(KGSL_NODE).exists() } + + /** At a session's start: pins the clock when the user asked for it. The line is for the log. */ + @Synchronized + fun start(context: Context): String { + if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed" + if (!supported) return "gpu clock: no $KGSL_NODE, left governed" + return try { + File(context.filesDir, MARKER).createNewFile() + WaylandCompositor.nativeSetGpuTurbo(true) + pinned = true + "gpu clock: held at its top" + } catch (t: Throwable) { + Log.w(TAG, "pin", t) + "gpu clock: pin failed" + } + } + + /** At a session's end. */ + @Synchronized + fun stop(context: Context) { + if (pinned) clear(context) + } + + /** At the app's start: a process killed with the clock pinned left only the marker behind. */ + @Synchronized + fun clearLeftover(context: Context) { + if (File(context.filesDir, MARKER).exists()) clear(context) + } + + private fun clear(context: Context) { + if (supported) { + try { + WaylandCompositor.nativeSetGpuTurbo(false) + } catch (t: Throwable) { + Log.w(TAG, "clear", t) + } + } + pinned = false + File(context.filesDir, MARKER).delete() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt index 7e99840d5..7a44423a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -17,7 +17,8 @@ import android.util.Log * Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to * the game; * - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to - * 120 Hz for the menu, not only when a game votes for it; + * 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode + * the cap divides evenly, so every capped frame is shown for the same number of refreshes; * - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases * its boost on loading screens or menus keeps it up. * @@ -27,7 +28,7 @@ import android.util.Log object PerfMode { private const val TAG = "PerfMode" - fun apply(a: Activity): String { + fun apply(a: Activity, fpsLimit: Int = 0): String { val parts = ArrayList(2) // The fastest display mode at the panel's current size. @@ -35,10 +36,12 @@ object PerfMode { val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else { val cur = display.mode - val best = display.supportedModes + val sameSize = display.supportedModes .filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight } - .maxByOrNull { it.refreshRate } - if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) { + val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate } + ?: sameSize.maxByOrNull { it.refreshRate } + if (best != null && best.modeId != cur.modeId && + (best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) { a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId } "display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})" } else "display ${cur.refreshRate.toInt()} Hz" @@ -64,4 +67,11 @@ object PerfMode { return parts.joinToString(" · ") } + + /** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */ + fun cadenceFits(hz: Float, fpsLimit: Int): Boolean { + if (fpsLimit <= 0) return true + val ratio = hz / fpsLimit + return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f + } } 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 4a0312cad..5de5ede5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -246,6 +246,13 @@ object SessionPrefs { * plainly exist is the signature - and the fallback is to trace everything instead: slower, * but correct. Max's advice for devices whose kernels "don't work well with it". */ + /** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */ + fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false) + + fun setGpuClockPin(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gpuClockPin", on).apply() + } + fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false) fun setProotNoSeccomp(context: Context, on: Boolean) { @@ -519,6 +526,22 @@ object SessionPrefs { prefs(context).edit().putBoolean("hdr.$mode", on).apply() } + /** + * The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's + * -r (what the client and its games see as the display's rate), the compositor's buffer release + * pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest + * one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a + * 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit + * does. Applies next session. + */ + fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0) + + fun setFpsLimit(context: Context, mode: String, fps: Int) { + prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply() + } + + val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120") + /** * The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a * fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices. 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 9a5465bd6..88ea6e93e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.input.FakeInputWriter import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -269,6 +270,8 @@ class SessionService : Service() { val sessionDir = openSessionFolder() val sessionLog = File(sessionDir, "session.log") + Log.i(TAG, GpuClockPin.start(this)) + val size = SessionState.outputSize val guest = ArrayList() // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the @@ -287,7 +290,7 @@ class SessionService : Service() { guest.add("DXVK_HDR=1") Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1") } - guest.add("BL_FPS=0") + guest.add("BL_FPS=" + SessionState.fpsLimit) guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) guest.add("BL_LOG=" + sessionLog.path) guest.add("BL_DEBUG_DIR=" + sessionDir.path) @@ -436,6 +439,7 @@ class SessionService : Service() { } sessionPid = pid if (pid > 1) { + raiseTracer(pid, gen) SessionEvents.guestStarted(pid) } else { SessionEvents.record("guest.start_failed", mapOf("pid" to pid)) @@ -528,6 +532,12 @@ class SessionService : Service() { // BL_STEAMDECK; it is the one that builds the command line). if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") + // Mesa's shader cache as one database instead of a file per entry. Every lookup in the + // file cache is an open and every store an open and a rename, each a proot stop (a rename + // two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is + // opened once and read and written with pread/pwrite, which proot never sees. Mesa removes + // the old folder itself once it has gone a week untouched. + guest.add("MESA_DISK_CACHE_DATABASE=1") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) @@ -968,6 +978,31 @@ class SessionService : Service() { } } + /** + * proot is one thread, and every syscall it traps anywhere in the session - a path lookup in + * Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to + * be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6, + * under the compositor's -8. After a moment, not at once: the first guest process is forked by + * proot itself and would inherit the value, and with it everything the session starts. + */ + private fun raiseTracer(pid: Int, gen: Int) { + Thread({ + try { + Thread.sleep(2000) + } catch (e: InterruptedException) { + return@Thread + } + if (gen != sessionGen || sessionPid != pid) return@Thread + val nice = try { + WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE) + } catch (t: Throwable) { + Log.w(TAG, "tracer priority", t) + return@Thread + } + Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)") + }, "tracer-priority").start() + } + private fun stopSession(status: Int) { synchronized(stopLock) { if (!SessionState.running) return @@ -976,6 +1011,7 @@ class SessionService : Service() { val stoppedGen = sessionGen SessionState.stopRequested = false SessionEvents.record("guest.exited", mapOf("status" to status)) + GpuClockPin.stop(this) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) suspendOperationPending = false launchWatcher?.stopWatching() @@ -1152,6 +1188,8 @@ class SessionService : Service() { companion object { private const val TAG = "SessionService" + /** proot's tracer: above everything in the guest, under the compositor thread's -8. */ + private const val TRACER_NICE = -6 /** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */ private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug" /** Downloads file of KEY=VALUE lines added to the session environment verbatim. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 71245d3fc..e197a6f8b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -95,6 +95,10 @@ object SessionState { @Volatile var refreshHz: Float = 60f + /** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */ + @Volatile + var fpsLimit: Int = 0 + /** The compositor has presented a frame, so the loading panel is behind us for this session. */ @Volatile var firstFrameSeen = false diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index fc41cd81e..3036b11fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -47,6 +47,8 @@ class ModeSettings( val shapeMode: String, val hdr: Boolean, val hdrReason: String?, + /** Frames per second the session is capped at; 0 = none. */ + val fpsLimit: Int = 0, val linuxRows: List, val linuxSelected: String, val androidRows: List, @@ -106,6 +108,7 @@ class ModeSettingsActions( val onCustomResolution: (Pair?) -> Unit = {}, val onShape: (String) -> Unit, val onHdr: (Boolean) -> Unit, + val onFpsLimit: (Int) -> Unit = {}, val onSelectLinux: (String) -> Unit, val onImportLinux: () -> Unit, val onRemoveLinux: (String) -> Unit, @@ -220,6 +223,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.", com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape, ) + ChoiceRow( + host, "fps", "Frame limit", "Applies next session.", + com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit, + note = "Caps the whole session. The screen switches to a rate the cap divides evenly.", + onPick = a.onFpsLimit, + ) if (editCustom) CustomResolutionDialog( initial = custom, onSave = { size -> editCustom = false; a.onCustomResolution(size) }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 1083babe2..fb441ef06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -38,6 +38,7 @@ fun PerformancePage( noGlError: Boolean, noXalia: Boolean, gamescopeRealtime: Boolean, + gpuClockPin: Boolean, prootNoSeccomp: Boolean, guestHostname: String, phantomWarning: String?, @@ -48,6 +49,7 @@ fun PerformancePage( onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, onGamescopeRealtime: (Boolean) -> Unit, + onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, @@ -102,6 +104,13 @@ fun PerformancePage( gamescopeRealtime, onChange = onGamescopeRealtime, ) } + SettingsGroup("GPU") { + ToggleRow( + host, "gpuclock", "Hold the GPU at its top clock", + "Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.", + gpuClockPin, onChange = onGpuClockPin, + ) + } SettingsGroup("Session fixes") { ToggleRow( host, "sysmem", "Turnip: sysmem rendering", 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 8e9fd35c7..a19bd858a 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -32,7 +32,9 @@ object CompositorHost { outputWidth: Int, outputHeight: Int, refreshHz: Float, + fpsLimit: Int, ): Boolean { + pace(refreshHz, fpsLimit) if (started) { attached = surface WaylandCompositor.nativeSetSurface(surface) @@ -41,10 +43,6 @@ object CompositorHost { } WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight) WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) - // The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate - // already being achieved, and on a phone whose vendor picks clocks from that a session - // that has been slowed reads as one that wants to be. (WinNative, WaylandSession.) - WaylandCompositor.nativeSetLayerFrameRate(refreshHz) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) @@ -54,6 +52,19 @@ object CompositorHost { return true } + /** + * The session's frame cap, on every attach: the compositor outlives sessions, and the next one + * may have a different cap. The release pacer hands a game one buffer back per capped frame + * (per refresh without a cap), and the zero-copy layer votes for the same cadence. Left at 0 + * the vote is inferred from the rate already being achieved, and on a phone whose vendor picks + * clocks from that a session that has been slowed reads as one that wants to be. (WinNative, + * WaylandSession.) + */ + private fun pace(refreshHz: Float, fpsLimit: Int) { + WaylandCompositor.nativeSetFpsLimit(fpsLimit) + WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz) + } + /** * The Surface changed size under the compositor (a foldable opening or closing). Android does * not always recreate the Surface for that, and a swapchain built for the old size keeps 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 9b7fda731..71c7172a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -207,6 +207,9 @@ public final class WaylandCompositor { */ public static native int nativeCursorSnapshot(int[] out); + /** The serial {@link #nativeCursorSnapshot} would report, without copying the image. */ + public static native int nativeCursorSerial(); + /** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */ public static final int CURSOR_BUF_INTS = 6 + 256 * 256; @@ -434,6 +437,14 @@ public final class WaylandCompositor { * back to the game, like the X11 IdleNotify pacer, so the game itself slows to the cap. */ public static native void nativeSetFpsLimit(int fps); + /** KGSL power control off (true: the GPU held at its top clock) or back on. Device-wide and + * outlives the process; see {@code GpuClockPin}. A no-op without /dev/kgsl-3d0. */ + public static native void nativeSetGpuTurbo(boolean on); + + /** setpriority() on one of our own processes or threads, only ever raising it (no cgroup move, + * unlike android.os.Process.setThreadPriority). Returns the nice it is left at; 100 = unknown. */ + public static native int nativeRaisePriority(int tid, int nice); + /** Inject the app's X-server input in scene (virtual desktop) pixels. type 2 = move to a,b; * 3 = evdev button a (BTN_LEFT=0x110…) pressed (b=1) or released (b=0); 4 = a wheel steps, * negative = up. */ diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists index 9d2d60fa7..61c8d35a6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists @@ -1,5 +1,6 @@ #!/bin/bash -# Marks Steam's shared redistributables as already run in every Proton prefix. +# Marks Steam's shared redistributables as already run in every Proton prefix, and in the +# template Proton makes new prefixes from. # # A game whose depot lists _CommonRedist makes the client run the real installer inside the # prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves @@ -14,27 +15,15 @@ set -u steam_root=${1:?steam root} redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" -[ -d "$redist" ] || exit 0 -# Every process an install script would run becomes the registry value the client writes once it -# has run it. Names sit one level inside "Run Process", the key they share a level further in. -block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk -v now="$(date +%s)" ' - function emit(key, name, path) { - path = key - sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) - if (path == key) - return - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; - # both spellings are seeded because the reader picks by its own word size. - sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - } +# Every process an install script would run, as the registry key and value the client writes once +# it has run it. Names sit one level inside "Run Process", the key they share a level further in. +pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk ' FNR == 1 { depth = 0; name = ""; key = "" } /^[[:space:]]*\{/ { depth++; next } /^[[:space:]]*\}/ { if (depth == 3 && name != "" && key != "") - emit(key, name) + printf "%s\t%s\n", key, name depth-- next } @@ -44,15 +33,62 @@ block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | x if (n >= 3 && depth == 2) { name = part[2]; key = "" } } ') -[ -n "$block" ] || exit 0 -stamp=$(printf '%s' "$block" | cksum) +# The client downloads the scripts with the first game that wants them, which is too late for that +# game; until then the set they held when this was written stands in. (WinNative's list.) +[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN' +DirectX\\Jun2010 dxsetup +.NET\\4.8 4.8 +vcredist\\2008 x86 SP1 +vcredist\\2008 x64 SP1 +vcredist\\2010 x86 +vcredist\\2010 x64 +vcredist\\2013 x86 12.0.30501 +vcredist\\2013 x64 12.0.30501 +vcredist\\2015 x86 Update 3 14.0.24215.0 +vcredist\\2015 x64 Update 3 14.0.24215.0 +vcredist\\2017 x86 14.10.25008.0 +vcredist\\2017 x64 14.10.25008.0 +vcredist\\2019 x86 14.28.29334.0 +vcredist\\2019 x64 14.28.29334.0 +vcredist\\2022 x86 14.51.36247.0 +vcredist\\2022 x64 14.51.36247.0 +KNOWN +) +# The stamp is the set of names, taken before the block below dates its keys: stamped from the +# dated block it never matched, and every prefix had the whole block appended again on every beat. +stamp=$(printf '%s' "$pairs" | cksum) -for reg in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ +block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" ' + { + path = $1 + sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) + if (path == $1) + next + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; + # both spellings are seeded because the reader picks by its own word size. + sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + } +') +[ -n "$block" ] || exit 0 + +# Proton builds a new prefix from its template, and the client runs the install scripts in that +# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a +# changed one as a new template and upgrades every prefix made from the old. +for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ + "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \ + "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ /mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue seeded=$(dirname "$reg")/.bannerlator-redists [ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue - printf '%s\n' "$block" >>"$reg" || continue - printf '%s\n' "$stamp" >"$seeded" - echo "== seeded redist markers into $reg" + dated=$(mktemp) || continue + touch -r "$reg" "$dated" + if printf '%s\n' "$block" >>"$reg"; then + printf '%s\n' "$stamp" >"$seeded" + echo "== seeded redist markers into $reg" + fi + touch -r "$dated" "$reg" + rm -f "$dated" done diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 39370ce73..f3b40af88 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -273,13 +273,24 @@ case $mode in # device it took 5 of 8, leaving out both little cores and the fastest one - which is a # reasonable thing to do while a game is running and the wrong thing when the client is all # there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns - # children continuously and each one inherits Steam's choice, not ours. + # children continuously and each one inherits Steam's choice, not ours. The beat reads /proc + # itself instead of running pgrep, and only runs taskset for a process that is new or has + # grown threads since it was last pinned: every fork is a round of traced syscalls, and a + # beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because + # without it only a process's main thread moves and the renderer's workers stay where they were. if [ -n "${BL_CLIENT_CPUS:-}" ]; then step "client cores: $BL_CLIENT_CPUS" ( - for _ in $(seq 1 720); do - for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do - taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + declare -A _bl_pinned=() + for ((_i = 0; _i < 720; _i++)); do + for _d in /proc/[0-9]*; do + IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue + case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac + _t=("$_d"/task/*) + _p=${_d#/proc/} + [ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue + taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + _bl_pinned[$_p]=${#_t[@]} done nap 5 done @@ -420,13 +431,33 @@ case $mode in prev=$app done ) & + # The library beat. Both helpers are whole programs under proot (the compat one a python + # start and a parse of the client's config), and nearly every beat has nothing new for them. + # The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born + # marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it + # reads from has changed since its last run - a new manifest, a new depot or tool, the client + # rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute + # regardless. ( beat=0 - for _ in $(seq 1 720); do + compat_stamp=$HOME/.bl-compat-beat + rm -f "$compat_stamp" + compat_due() { + local d + [ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0 + for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \ + "$steam_root/compatibilitytools.d" /mnt/bannerlator-sd/steamapps; do + [ "$d" -nt "$compat_stamp" ] && return 0 + done + return 1 + } + for ((_i = 0; _i < 720; _i++)); do nap 5 - bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true beat=$((beat + 1)) - if [ "$((beat % 3))" -eq 0 ]; then + [ "$((beat % 3))" -eq 0 ] || continue + bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true + if compat_due; then + : > "$compat_stamp" bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true fi done diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 28bb1f89d..fb70b40e2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -152,6 +152,9 @@ bl_game_cpus() { # container that path never reaches Proton either. (WinNative's winnative-proton-launch.) BL_LIBPATH_SETUP = """ bl_libpath() { + # A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread + # here, where only PulseAudio answers. (WinNative, winnative-proton-launch.) + export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0} [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 local kept= dir old_ifs=$IFS IFS=: @@ -246,17 +249,29 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +# The newest of the ARM64 depots the client has, by the build number that opens each one's version +# file: taking the first one found ran a stale Experimental over a newer 11.0. (WinNative.) +depot= +best=0 for name in %s; do - depot="$steam_root/steamapps/common/$name" - if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then - bl_components "$depot" - bl_directaudio "$depot" - bl_game_cpus - bl_idtech3 "$@" - bl_libpath - exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" + candidate="$steam_root/steamapps/common/$name" + [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue + build=0 + read -r build _ 2>/dev/null < "$candidate/version" + case $build in "" | *[!0-9]*) build=0 ;; esac + if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then + depot=$candidate + best=$build fi done +if [ -n "$depot" ]; then + bl_components "$depot" + bl_directaudio "$depot" + bl_game_cpus + bl_idtech3 "$@" + bl_libpath + exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" +fi echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 exit 1 """ % " ".join('"%s"' % n for n in SOURCES) @@ -568,15 +583,23 @@ def register_default(config_path, apps=(), name=None, protect=()): write_changed(config_path, text) +def depot_build(depot): + """The build number a depot's version file starts with; 0 when there is none.""" + try: + with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f: + first = f.read().split(None, 1) + except OSError: + return 0 + return int(first[0]) if first and first[0].isdigit() else 0 + + def find_source(steam_root): - """The ARM64 Proton depot the client has downloaded, or None.""" + """The newest ARM64 Proton depot the client has downloaded (the one the launcher runs), or None.""" common = os.path.join(steam_root, "steamapps", "common") - for name in SOURCES: - depot = os.path.join(common, name) - # bin-arm64 is what makes it the native build rather than the x86_64 one. - if os.path.isdir(os.path.join(depot, "files", "bin-arm64")): - return depot - return None + # bin-arm64 is what makes it the native build rather than the x86_64 one. + found = [os.path.join(common, name) for name in SOURCES + if os.path.isdir(os.path.join(common, name, "files", "bin-arm64"))] + return max(found, key=depot_build) if found else None def build_tool(tool_dir): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 3ab3e9844..1c2add014 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -128,6 +128,22 @@ def probe(steam_root, ids): method(client, 1, C.c_bool, C.c_int)(client, pipe) +def game_running(): + """Whether a Proton game is up: a wineserver is there for as long as any prefix is in use.""" + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open("/proc/%s/comm" % pid) as f: + if f.read().strip() == "wineserver": + return True + except OSError: + pass + return False + + def watch(steam_root, listing): games = read_json(listing, []) if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: @@ -138,7 +154,10 @@ def watch(steam_root, listing): # The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership # only moves with a purchase or a new match, so it runs when the matches or their sources # change, after a failed probe, and otherwise every five minutes - not on every 30 s beat. - probed, next_probe = None, 0 + # A failed probe is retried on a doubling delay up to those five minutes rather than on every + # beat (a client that is not logged on fails it for as long as it stays that way), and none + # starts while a game is running: the answer can wait, the game's cores cannot. + probed, next_probe, failures = None, 0, 0 while True: now = time.monotonic() for game in games: @@ -155,7 +174,9 @@ def watch(steam_root, listing): next_lookup = now + 300 status = "waiting for a matching Steam title" key = (dict(candidates), dict(sources)) - if candidates and (key != probed or now >= next_probe): + if candidates and (key != probed or now >= next_probe) and game_running(): + status = last_status + elif candidates and (key != probed or now >= next_probe): probed, next_probe = key, now + 300 try: result = subprocess.run([sys.executable, __file__, "probe", steam_root, @@ -163,8 +184,10 @@ def watch(steam_root, listing): capture_output=True, text=True, timeout=15) if result.returncode != 0: status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) - probed = None + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) else: + failures = 0 line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0: @@ -175,7 +198,8 @@ def watch(steam_root, listing): status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values())) except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: status = "ownership check unavailable: " + str(error) - probed = None + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) elif candidates: status = last_status if status != last_status: diff --git a/tools/linuxfs/preload/kgslpoll.c b/tools/linuxfs/preload/kgslpoll.c new file mode 100644 index 000000000..00eb8f01f --- /dev/null +++ b/tools/linuxfs/preload/kgslpoll.c @@ -0,0 +1,79 @@ +/* + * A zero-timeout KGSL timestamp wait answered as the poll it was meant to be. + * + * Turnip's KGSL backend emulates timeline semaphores with timestamps, and asks "has this one + * retired yet?" by waiting on it with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0. + * KGSL reads a timeout of 0 as no timeout at all, so the poll blocks until the GPU gets there. + * Mesa's timeline bookkeeping polls like that under a lock on every signalling submit, which put + * vkd3d-proton, DXVK, gamescope and Zink in lock-step with the GPU: one frame queued, never two. + * Found and measured by Bannerlator (PROGRESS_LOG 2026-09-29: a D3D12 demo went from 614 to + * 4335 fps with the same answer given in its Vulkan adapter); the driver-side fix is on + * Banners-Turnip, and this covers the drivers that do not carry it yet. + * + * The answer comes from the context's retired timestamp, compared the way KGSL compares them + * (with wrap-around). Anything the read does not answer - an unknown context, a device that is not + * KGSL - goes to the kernel as before. BL_NO_KGSL_POLL_FIX=1 turns it off. + */ +#define _GNU_SOURCE +#include +#include +#include +#include + +#define KGSL_IOC_TYPE 0x09 +#define KGSL_TIMESTAMP_RETIRED 0x00000002 + +struct kgsl_device_waittimestamp_ctxtid { + unsigned int context_id; + unsigned int timestamp; + unsigned int timeout; +}; + +struct kgsl_cmdstream_readtimestamp_ctxtid { + unsigned int context_id; + unsigned int type; + unsigned int timestamp; +}; + +#define IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID \ + _IOW(KGSL_IOC_TYPE, 0x7, struct kgsl_device_waittimestamp_ctxtid) +#define IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID \ + _IOWR(KGSL_IOC_TYPE, 0x16, struct kgsl_cmdstream_readtimestamp_ctxtid) + +static int enabled(void) { + static int cached = -1; + const char *off; + + if (cached >= 0) return cached; + off = getenv("BL_NO_KGSL_POLL_FIX"); + cached = !(off != NULL && off[0] == '1'); + return cached; +} + +/* True when the call was answered here, with its result in *rc (and errno set on failure). */ +__attribute__((visibility("hidden"))) int bl_kgsl_poll(int (*real)(int, unsigned long, void *), + int fd, unsigned long request, void *arg, + int *rc) { + const struct kgsl_device_waittimestamp_ctxtid *wait = arg; + struct kgsl_cmdstream_readtimestamp_ctxtid read; + int saved = errno; + + if (request != IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID || arg == NULL || wait->timeout != 0 || + !enabled()) + return 0; + read.context_id = wait->context_id; + read.type = KGSL_TIMESTAMP_RETIRED; + read.timestamp = 0; + if (real(fd, IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID, &read) != 0) { + errno = saved; + return 0; + } + if ((int32_t)(read.timestamp - wait->timestamp) >= 0) { + errno = saved; + *rc = 0; + } else { + errno = ETIMEDOUT; + *rc = -1; + } + return 1; +} diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 4b79c3f3f..a835c6cdd 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -349,6 +349,9 @@ static int is_link_query(unsigned long request) { || request == SIOCGIFBRDADDR; } +int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, + void *arg, int *rc) __attribute__((visibility("hidden"))); + /* Every DRM and evdev call comes through here, so the lookup is done once. Two threads racing * to store it store the same pointer. */ int ioctl(int fd, unsigned long request, ...) { @@ -360,7 +363,9 @@ int ioctl(int fd, unsigned long request, ...) { va_end(ap); if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); - int rc = real(fd, request, arg); + int rc; + if (bl_kgsl_poll(real, fd, request, arg, &rc)) return rc; + rc = real(fd, request, arg); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; if (answer_ifreq(request, (struct ifreq *) arg, rc)) return 0;