Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5eea458b4 | ||
|
|
7f141eb9a4 | ||
|
|
06086de328 | ||
|
|
7b91afd721 | ||
|
|
0ed4b51104 | ||
|
|
3197a4e887 | ||
|
|
df74dd5aee | ||
|
|
b2020396c9 | ||
|
|
b20184462d | ||
|
|
537a1852ed | ||
|
|
5819cf054b | ||
|
|
62624c1daf | ||
|
|
7f6d1622ee | ||
|
|
5c1db4662f | ||
|
|
4b514cc07c | ||
|
|
30bd10e301 |
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
val gamepadUi = gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
|
||||
@@ -67,7 +67,7 @@ class GamepadPalette(
|
||||
)
|
||||
|
||||
/**
|
||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
@@ -77,6 +77,22 @@ class GamepadPalette(
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// brand violet — focus has to be findable on black.
|
||||
"oled", "OLED",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
Triple(0.120, 0.024, 0.130),
|
||||
),
|
||||
ground = Triple(0.0, 0.0, 0.0),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
|
||||
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
) + listOfNotNull(
|
||||
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||
// on a screen that is itself about to disappear.
|
||||
if (s.gamepadUiEnabled) {
|
||||
choice(
|
||||
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||
"With a controller: the touch interface comes back when the last one " +
|
||||
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||
) { update(s.copy(gamepadUiMode = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||
* the switch meant when it was a lone Boolean.
|
||||
*/
|
||||
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||
* always awake.
|
||||
*/
|
||||
const val GAMEPAD_UI_ALWAYS = "always"
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
|
||||
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
|
||||
* the only input, so it's always the console UI (as long as the setting is on), which is why the
|
||||
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||
* client wrote can never strand this one in a layout it has no way back out of.
|
||||
*/
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
fun gamepadUiActive(
|
||||
enabled: Boolean,
|
||||
mode: String,
|
||||
controllerConnected: Boolean,
|
||||
tv: Boolean,
|
||||
forced: Boolean,
|
||||
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
|
||||
@@ -94,11 +94,20 @@ data class Settings(
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||
*/
|
||||
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
@@ -107,9 +116,10 @@ data class Settings(
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
|
||||
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
|
||||
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
|
||||
* client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
)
|
||||
}
|
||||
|
||||
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||
GAMEPAD_UI_ALWAYS to "Always",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home when a controller is connected. A TV " +
|
||||
"always uses it.",
|
||||
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||
"gamepad chrome. A TV always uses it.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
// Only decides anything while the switch above is on, so it is HIDDEN rather than
|
||||
// dimmed when it isn't — a picker whose every option changes nothing is worse than
|
||||
// no picker, and this group is short enough that nothing jumps far.
|
||||
if (s.gamepadUiEnabled) {
|
||||
SettingDropdown(
|
||||
label = "Show it",
|
||||
options = GAMEPAD_UI_MODE_OPTIONS,
|
||||
selected = s.gamepadUiMode,
|
||||
caption = "With a controller: the touch home comes back when the last one " +
|
||||
"disconnects. Always keeps the console home either way — for a device " +
|
||||
"that lives docked to a TV.",
|
||||
) { v -> update(s.copy(gamepadUiMode = v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||
),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||
assertEquals(6, firstLight)
|
||||
assertEquals(7, firstLight)
|
||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||
*/
|
||||
@Test
|
||||
fun oledIsActuallyBlack() {
|
||||
val oled = GamepadPalette.named("oled")
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||
val darkestOther = GamepadPalette.ALL
|
||||
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
|
||||
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||
* be one last thing to step past on the way out.
|
||||
*/
|
||||
@Test
|
||||
fun `the activation-mode row follows the switch it belongs to`() {
|
||||
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||
Settings(gamepadUiEnabled = enabled),
|
||||
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) {}.map { it.id }
|
||||
|
||||
val on = ids(enabled = true)
|
||||
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||
assertEquals(
|
||||
"the mode belongs directly under the switch it qualifies",
|
||||
on.indexOf("gamepadUI") + 1,
|
||||
on.indexOf("gamepadUIMode"),
|
||||
)
|
||||
val off = ids(enabled = false)
|
||||
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||
}
|
||||
|
||||
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||
@Test
|
||||
fun `the activation-mode row steps the shared key`() {
|
||||
var s = Settings()
|
||||
fun mode() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertEquals("With a controller", mode().value)
|
||||
assertFalse("already the first = thud", mode().adjust(-1))
|
||||
assertTrue(mode().adjust(1))
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||
// A from the last entry wraps home.
|
||||
mode().activate()
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||
* disagreement here is a device that behaves differently from the same setting.
|
||||
*/
|
||||
class GamepadUiTest {
|
||||
|
||||
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||
@Test
|
||||
fun whenConnectedWaitsForAPad() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||
}
|
||||
|
||||
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||
* way back to the touch UI. */
|
||||
@Test
|
||||
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||
* layout it has no way back out of. */
|
||||
@Test
|
||||
fun anUnknownModeWaitsForAPad() {
|
||||
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** The shipped default: the console UI still waits for a controller. */
|
||||
@Test
|
||||
fun theDefaultIsUnchangedBehaviour() {
|
||||
val s = Settings()
|
||||
assertTrue(s.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ class ProfilesTest {
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
|
||||
@@ -99,6 +99,10 @@ struct ContentView: View {
|
||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — "connected" (default) or "always". See
|
||||
/// `GamepadUIEnvironment`.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@@ -113,7 +117,8 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
|
||||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||||
|
||||
@@ -85,16 +85,40 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
|
||||
/// gamepad screens' common root so no individual view has to read the setting.
|
||||
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
|
||||
/// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to
|
||||
/// everything below. Applied by the gamepad screens' common root so no individual view has to
|
||||
/// read the setting.
|
||||
///
|
||||
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
|
||||
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
|
||||
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
|
||||
/// palette's scheme would invert their own system colours instead of matching them.
|
||||
func gamepadPaletteInk(_ active: Bool = true) -> some View {
|
||||
modifier(GamepadInkModifier(active: active))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadInkModifier: ViewModifier {
|
||||
var active = true
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
/// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the
|
||||
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
|
||||
/// that would change this view's identity.
|
||||
@Environment(\.colorScheme) private var systemScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
return content
|
||||
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
|
||||
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
|
||||
// screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a
|
||||
// NavigationStack's title, a material's frost — resolves against the DEVICE's
|
||||
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
|
||||
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
|
||||
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
|
||||
// the interface was unreadable. Publishing the scheme here — once, beside the ink it
|
||||
// has to agree with — is what makes a pale palette mean "light" to UIKit too.
|
||||
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,12 @@ struct LibraryView: View {
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -78,6 +81,16 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Published HERE, not just inside the coverflow, because the coverflow is only one of
|
||||
// four things this view renders: the loading spinner, the error state and the empty
|
||||
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
|
||||
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
|
||||
// directly in a NavigationStack, so under a pale palette every one of them kept the
|
||||
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
|
||||
// UI isn't drawing — the plain grid belongs to the system background.
|
||||
.gamepadPaletteInk(gamepadUIActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
|
||||
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — the row is only built while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
// WHEN the switch above takes over. Built only while it is on: with the switch off this
|
||||
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
|
||||
// that decides nothing would exist purely to be found in a screenshot.
|
||||
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
|
||||
list.insert(
|
||||
choiceRow(
|
||||
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
|
||||
label: "Show it",
|
||||
detail: "With a controller: the touch interface comes back when the last one "
|
||||
+ "disconnects. Always keeps this layout either way — for a device that "
|
||||
+ "lives on a TV.",
|
||||
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
|
||||
) { gamepadUIMode = $0 },
|
||||
at: at + 1)
|
||||
}
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
// The smoothness buffer only decides anything under Smoothness. Every other settings
|
||||
// surface — touch, tvOS, the GTK and WinUI shells — hides it under Lowest latency; this
|
||||
// screen alone left it live and steppable, which is a row that thuds or silently stores
|
||||
// a value nothing reads. Removed here rather than omitted from the literal above so the
|
||||
// macOS safe-present insertion can still anchor on it.
|
||||
if presentPriority != "smooth" {
|
||||
list.removeAll { $0.id == "smoothBuffer" }
|
||||
}
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while
|
||||
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
|
||||
/// is off rather than showing a picker that decides nothing.
|
||||
static let gamepadUIModes: [(label: String, tag: String)] = [
|
||||
("With a controller", GamepadUIEnvironment.modeWhenConnected),
|
||||
("Always", GamepadUIEnvironment.modeAlways),
|
||||
]
|
||||
|
||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||
|
||||
@@ -724,11 +724,24 @@ extension SettingsView {
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
described("The host list and library switch to a controller-friendly layout — "
|
||||
+ "larger focus targets, a swipeable cover browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
}
|
||||
// Only meaningful while the switch above is on, so it is HIDDEN rather than
|
||||
// disabled when it isn't: a picker whose every option decides nothing is worse
|
||||
// than no picker, and this Section is short enough that nothing jumps far.
|
||||
if gamepadUIEnabled {
|
||||
described("With a controller: the touch interface comes back when the last "
|
||||
+ "one disconnects. Always keeps the controller-friendly layout either "
|
||||
+ "way — for a device that lives on a TV.") {
|
||||
Picker("Show it", selection: $gamepadUIMode) {
|
||||
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
|
||||
@@ -75,6 +75,13 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — read (and shown) only while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) — every other
|
||||
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
|
||||
/// controller cannot open.
|
||||
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
@@ -488,6 +495,22 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
// Hidden while the switch above is off — see the touch settings' identical gate.
|
||||
if gamepadUIEnabled {
|
||||
TVSelectionRow(
|
||||
title: "Show it",
|
||||
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
|
||||
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
|
||||
// Background row lives on the gamepad settings screen, which is reached from
|
||||
// the gamepad launcher — and on tvOS that launcher needs an extended-profile
|
||||
// controller, so an Apple TV driven by the Siri Remote alone could not reach
|
||||
// the palettes at all. It belongs beside "Show it" because both describe the
|
||||
// same interface: this row is what that interface looks like once it is up.
|
||||
TVSelectionRow(
|
||||
title: "Background",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
selection: $uiPalette)
|
||||
}
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("About") { AboutView() }
|
||||
.padding(.top, 8)
|
||||
|
||||
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
|
||||
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
|
||||
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
|
||||
// against the device appearance — which is how the pale palettes came out light-on-light
|
||||
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
|
||||
// tvOS and pre-26 branches were the odd ones out.
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
||||
in: shape)
|
||||
.environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
|
||||
content
|
||||
.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,40 @@
|
||||
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
|
||||
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
|
||||
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
|
||||
/// and what the switch meant when it was a lone Bool.
|
||||
public static let modeWhenConnected = "connected"
|
||||
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad — asked
|
||||
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
|
||||
/// one they want and the pad is not always awake.
|
||||
public static let modeAlways = "always"
|
||||
|
||||
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) — off means
|
||||
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
|
||||
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
|
||||
/// value a newer client wrote) waits for a controller.
|
||||
///
|
||||
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
||||
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
|
||||
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
|
||||
/// without a real `GCController` (which XCTest can't construct).
|
||||
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
|
||||
enabledSetting && (gamepadConnected || forced)
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
|
||||
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
|
||||
/// (which XCTest can't construct).
|
||||
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
|
||||
/// everyone who picked Always back on "only with a controller", which is exactly the bug
|
||||
/// this parameter exists to make impossible.
|
||||
public static func isActive(
|
||||
gamepadConnected: Bool,
|
||||
enabledSetting: Bool,
|
||||
mode: String
|
||||
) -> Bool {
|
||||
guard enabledSetting else { return false }
|
||||
return mode == modeAlways || gamepadConnected || forced
|
||||
}
|
||||
|
||||
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
|
||||
|
||||
@@ -176,16 +176,23 @@ public enum DefaultsKey {
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
|
||||
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default — only while a
|
||||
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
|
||||
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
|
||||
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
|
||||
/// settings rows hide it when the switch is off. Anything unrecognized reads as
|
||||
/// `"connected"`. A device preference, never part of a stream profile.
|
||||
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
|
||||
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
|
||||
/// console and the Android client carry the same table under the same names. Presentation
|
||||
/// only, so it is a device preference and never part of a stream profile. An unknown value
|
||||
/// reads as the default rather than failing — a newer client may have shipped a palette this
|
||||
/// build doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
|
||||
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
||||
]
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
/// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way.
|
||||
public static let all: [GamepadPalette] = [
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
id: "violet", name: "Violet", stops: [],
|
||||
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The first two stops are literally (0,0,0), so the shaded half of the
|
||||
// field is genuinely off rather than "very dark grey", and the ground is pure black
|
||||
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
||||
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||
// — focus has to be findable on black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
||||
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||
SIMD3(0.120, 0.024, 0.130)],
|
||||
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
|
||||
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
||||
XCTAssertEqual(firstLight, 6)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
|
||||
XCTAssertEqual(firstLight, 7)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
/// not merely the darkest of the dark fields.
|
||||
func testOLEDIsActuallyBlack() {
|
||||
let oled = GamepadPalette.named("oled")
|
||||
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
|
||||
let cells = oled.meshColors
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
cells.filter { luma($0) == 0 }.count, 3,
|
||||
"the shaded corner has to be switched off, not dimmed")
|
||||
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
|
||||
let darkestOther = GamepadPalette.all
|
||||
.filter { $0.id != "oled" }
|
||||
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
|
||||
.min() ?? 0
|
||||
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
|
||||
}
|
||||
|
||||
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs.
|
||||
// GamepadUIEnvironment.isActive is pure — table-tested exhaustively over its inputs.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadUIEnvironmentTests: XCTestCase {
|
||||
func testActiveOnlyWhenEnabledAndConnected() {
|
||||
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
|
||||
private let connected = GamepadUIEnvironment.modeWhenConnected
|
||||
private let always = GamepadUIEnvironment.modeAlways
|
||||
|
||||
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
|
||||
/// that never sees the new row is exactly where it was.
|
||||
func testWhenConnectedIsAPlainAnd() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: connected))
|
||||
}
|
||||
|
||||
/// Always drops the controller from the decision entirely — but NOT the switch, which stays
|
||||
/// the one way back to the touch UI.
|
||||
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: always))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: always))
|
||||
}
|
||||
|
||||
/// A value a newer client wrote must wait for a controller, never strand this build in a
|
||||
/// layout it has no way back out of.
|
||||
func testUnknownModeWaitsForAController() {
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,16 @@ struct Args {
|
||||
/// host must composite the metadata cursor on its own; decode the dump and look for the
|
||||
/// pointer.
|
||||
cursor_nochannel: bool,
|
||||
/// `--cursor-hold` — with `--cursor-capture`/`--cursor-nochannel`, stop the relative wiggle
|
||||
/// after a short priming burst instead of circling forever. The wiggle exists to keep a
|
||||
/// damage-driven desktop publishing frames, but it also DRAGS the host pointer several hundred
|
||||
/// pixels a second, which makes it impossible to hold the pointer over a chosen target — and
|
||||
/// the shape under the pointer is the whole point when the question is "does the MONOCHROME
|
||||
/// I-beam survive compositing?" (the arrow is a colour cursor and proves nothing about the
|
||||
/// mono path). With this flag: prime for ~3 s so the pointer is un-suppressed and metadata is
|
||||
/// flowing, then hold still so a `SetCursorPos` on the host can park it on a text field for
|
||||
/// the rest of the dump.
|
||||
cursor_hold: bool,
|
||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
@@ -309,6 +319,7 @@ fn parse_args() -> Args {
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
|
||||
cursor_hold: argv.iter().any(|a| a == "--cursor-hold"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,13 +911,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let wiggle_conn = conn.clone();
|
||||
let hold = args.cursor_hold;
|
||||
tokio::spawn(async move {
|
||||
// Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump.
|
||||
// Relative circles: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump — unless
|
||||
// `--cursor-hold`, which primes and then stops so the pointer can be parked.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
tracing::info!("cursor-capture: relative pointer wiggle running");
|
||||
tracing::info!(hold, "cursor-capture: relative pointer wiggle running");
|
||||
let prime_until = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
let mut t = 0.0f64;
|
||||
loop {
|
||||
if hold && std::time::Instant::now() >= prime_until {
|
||||
tracing::info!(
|
||||
"cursor-capture: wiggle primed and STOPPED (--cursor-hold) — the pointer \
|
||||
now stays where the host puts it"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let e = InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
|
||||
@@ -212,6 +212,47 @@ struct KeyedMutexGuard<'a> {
|
||||
/// (`frame_transport.rs`).
|
||||
const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080;
|
||||
|
||||
/// First retry delay after a composite-blend failure — short enough that a transient device-loss
|
||||
/// costs a few pointer-less frames rather than the rest of the session.
|
||||
const BLEND_RETRY_MIN: Duration = Duration::from_millis(250);
|
||||
/// Ceiling for the doubling retry: a genuinely broken device stops burning a frame-sized texture
|
||||
/// allocation every quarter second, while still recovering within ~4 s if it ever comes back.
|
||||
const BLEND_RETRY_MAX: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long the poller may publish NOTHING before the capturer calls it wedged. It polls at
|
||||
/// `CursorPoller::INTERVAL` (4 ms), so this is ~250 missed publishes — far outside any scheduling
|
||||
/// hiccup, and still fast enough to name the fault while a user is still looking at it.
|
||||
const POLLER_STALL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The next retry delay after a composite-blend failure: [`BLEND_RETRY_MIN`] for the first, then
|
||||
/// doubling per consecutive failure up to [`BLEND_RETRY_MAX`]. Free function so the escalation is
|
||||
/// testable without a live D3D11 device (the `mono_planes_to_rgba` precedent — the arithmetic a
|
||||
/// bug would hide in does not need the plumbing around it).
|
||||
fn next_blend_backoff(prev: Option<Duration>) -> Duration {
|
||||
prev.map_or(BLEND_RETRY_MIN, |b| (b * 2).min(BLEND_RETRY_MAX))
|
||||
}
|
||||
|
||||
/// The composite-regen change key for an overlay: what a blend would DRAW — `(serial, x, y)` for a
|
||||
/// visible pointer, `None` when nothing would be drawn. ONE definition, used by both the regen test
|
||||
/// and the blend itself, because the two drifting apart is precisely the bug shape here: a key that
|
||||
/// says "changed" while the drawn frame is identical re-encodes for nothing, and a key that says
|
||||
/// "unchanged" while the pointer moved freezes it on screen.
|
||||
fn blend_key_of(ov: Option<&pf_frame::CursorOverlay>) -> Option<(u64, i32, i32)> {
|
||||
ov.filter(|o| o.visible).map(|o| (o.serial, o.x, o.y))
|
||||
}
|
||||
|
||||
/// A composite-blend failure and its pending retry ([`IddPushCapturer::blend_fail`]).
|
||||
struct BlendFail {
|
||||
/// No blend is attempted before this instant.
|
||||
retry_at: Instant,
|
||||
/// The delay that produced `retry_at`; doubles per consecutive failure up to
|
||||
/// [`BLEND_RETRY_MAX`].
|
||||
backoff: Duration,
|
||||
/// Consecutive failures without an intervening success — logged, so a session that is
|
||||
/// permanently pointer-less is distinguishable from one that hiccupped once.
|
||||
consecutive: u32,
|
||||
}
|
||||
|
||||
impl<'a> KeyedMutexGuard<'a> {
|
||||
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
|
||||
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
|
||||
@@ -385,13 +426,26 @@ pub struct IddPushCapturer {
|
||||
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
|
||||
/// it off (there is no channel to hand the pointer to).
|
||||
composite_forced: bool,
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
|
||||
/// composite mode then degrades to pointer-less frames (warned once).
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` before the first blend and
|
||||
/// after a failure dropped it; rebuilt on the next attempt that is not suppressed.
|
||||
cursor_blend: Option<cursor_blend::CursorBlendPass>,
|
||||
cursor_blend_failed: bool,
|
||||
/// Composite-blend failure state. `None` = healthy. A failure used to be TERMINAL — one warn,
|
||||
/// a sticky flag, and the session then streamed a pointer-less desktop for its whole life —
|
||||
/// but the causes that actually occur (device loss, a transient allocation failure on the
|
||||
/// frame-sized scratch) heal, and the pointer is the one thing a capture-model session cannot
|
||||
/// do without. So a failure now only suppresses the blend until `retry_at`, doubling from
|
||||
/// [`BLEND_RETRY_MIN`] to [`BLEND_RETRY_MAX`] while failures continue, and the first success
|
||||
/// clears it.
|
||||
blend_fail: Option<BlendFail>,
|
||||
/// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources
|
||||
/// keep independent serial namespaces, so once crossed we never go back (see there).
|
||||
cursor_shm_latched: bool,
|
||||
/// Poller heartbeat watch: the last sampled publish count and when it last ADVANCED. A poller
|
||||
/// that is `alive()` but wedged stops advancing it while never exiting — invisible before.
|
||||
cursor_poll_watch: (u64, Instant),
|
||||
/// Whether the wedged-poller warning has already been emitted for the CURRENT stall (cleared
|
||||
/// when it resumes), so a permanently wedged poller warns once rather than every tick.
|
||||
cursor_poll_stalled: bool,
|
||||
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
|
||||
/// it was built for — rebuilt when the ring geometry changes.
|
||||
blend_scratch: Option<(
|
||||
@@ -401,10 +455,12 @@ pub struct IddPushCapturer {
|
||||
u32,
|
||||
DXGI_FORMAT,
|
||||
)>,
|
||||
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
|
||||
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
|
||||
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
|
||||
last_blend_key: Option<(u64, i32, i32, bool)>,
|
||||
/// What the LAST blend actually DREW — the composite-regen change key: pointer-only motion
|
||||
/// produces no driver publish (the declared hardware cursor doesn't dirty frames), so
|
||||
/// `try_consume` regenerates from the last slot when this changes. `None` = the frame carries
|
||||
/// no pointer (hidden or no shape yet), which is why a HIDDEN pointer's position is not part
|
||||
/// of the key — see [`Self::cursor_blend_key`].
|
||||
last_blend_key: Option<(u64, i32, i32)>,
|
||||
/// The ring slot of the last FRESH publish — the regen source.
|
||||
last_slot: Option<usize>,
|
||||
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
|
||||
@@ -1211,10 +1267,17 @@ impl IddPushCapturer {
|
||||
/// poller meant pointer-less frames, not a degraded pointer.
|
||||
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
if !self.cursor_shm_latched {
|
||||
if let Some(p) = &self.cursor_poll {
|
||||
if p.alive() {
|
||||
return p.read();
|
||||
}
|
||||
// Sample the heartbeat and the snapshot together, then drop the borrow so the watch
|
||||
// can take `&mut self`. `alive()` is liveness only — `watch_cursor_publishes` is what
|
||||
// tells a working poller apart from a wedged one.
|
||||
let sampled = self
|
||||
.cursor_poll
|
||||
.as_ref()
|
||||
.filter(|p| p.alive())
|
||||
.map(|p| (p.publishes(), p.read()));
|
||||
if let Some((n, overlay)) = sampled {
|
||||
self.watch_cursor_publishes(n);
|
||||
return overlay;
|
||||
}
|
||||
// The poller is gone (or never started) and we are about to read the shm — latch, so a
|
||||
// poller that somehow reports alive again cannot re-cross the serial namespaces.
|
||||
@@ -1255,17 +1318,91 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
|
||||
/// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
|
||||
/// `None` while no source has a shape yet.
|
||||
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
|
||||
self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
|
||||
/// Watch the GDI poller's heartbeat and log the transitions. The poller is the ONLY
|
||||
/// full-fidelity shape source (the driver's query is alpha-only — `cursor_poll.rs`), so a
|
||||
/// poller that is alive but no longer publishing freezes the pointer in every frame at its
|
||||
/// last sampled shape and position. That state used to be completely silent: `alive()` stays
|
||||
/// true, the slot keeps returning its last snapshot, and nothing in the log distinguishes it
|
||||
/// from a genuinely motionless pointer.
|
||||
fn watch_cursor_publishes(&mut self, n: u64) {
|
||||
let (last, since) = self.cursor_poll_watch;
|
||||
if n != last {
|
||||
self.cursor_poll_watch = (n, Instant::now());
|
||||
if self.cursor_poll_stalled {
|
||||
self.cursor_poll_stalled = false;
|
||||
tracing::info!(
|
||||
target_id = self.target_id,
|
||||
"cursor poller resumed publishing — the pointer tracks again"
|
||||
);
|
||||
}
|
||||
} else if !self.cursor_poll_stalled && since.elapsed() >= POLLER_STALL {
|
||||
self.cursor_poll_stalled = true;
|
||||
tracing::warn!(
|
||||
target_id = self.target_id,
|
||||
stalled_ms = since.elapsed().as_millis() as u64,
|
||||
"cursor poller is ALIVE but has stopped publishing — the pointer is frozen at its \
|
||||
last sampled shape/position (input-desktop reads failing every tick?)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the composite blend currently suppressed by a failure's backoff?
|
||||
fn blend_suppressed(&self) -> bool {
|
||||
self.blend_fail
|
||||
.as_ref()
|
||||
.is_some_and(|f| Instant::now() < f.retry_at)
|
||||
}
|
||||
|
||||
/// Record a composite-blend failure and arm the next retry (see [`BlendFail`]). Logs EVERY
|
||||
/// escalation rather than only the first — a pointer-less capture-model session is a
|
||||
/// user-visible fault, and the old warn-once left a permanently broken one indistinguishable
|
||||
/// in the log from a single transient hiccup at startup.
|
||||
fn note_blend_failure(&mut self, why: &str) {
|
||||
let backoff = next_blend_backoff(self.blend_fail.as_ref().map(|f| f.backoff));
|
||||
let consecutive = self.blend_fail.as_ref().map_or(1, |f| f.consecutive + 1);
|
||||
self.blend_fail = Some(BlendFail {
|
||||
retry_at: Instant::now() + backoff,
|
||||
backoff,
|
||||
consecutive,
|
||||
});
|
||||
tracing::warn!(
|
||||
consecutive,
|
||||
retry_in_ms = backoff.as_millis() as u64,
|
||||
"cursor composite: {why} — frames stay pointer-less until the retry succeeds"
|
||||
);
|
||||
}
|
||||
|
||||
/// A blend succeeded: retire any failure record so the next one starts at the short backoff.
|
||||
fn note_blend_success(&mut self) {
|
||||
if let Some(f) = self.blend_fail.take() {
|
||||
tracing::info!(
|
||||
after_consecutive_failures = f.consecutive,
|
||||
"cursor composite: blend recovered — the pointer is back in frames"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a blend would DRAW this tick — `(serial, x, y)` for a visible pointer, `None` for a
|
||||
/// hidden or not-yet-known one. Keyed on the drawn RESULT rather than on raw cursor state so
|
||||
/// that a HIDDEN pointer moving — routine, because that is exactly what a game that grabbed
|
||||
/// the pointer does — cannot force a frame regeneration on an otherwise idle desktop. The
|
||||
/// visible⇄hidden transitions still change the key (`Some`⇄`None`), so the frame that must
|
||||
/// gain or lose the pointer is still regenerated.
|
||||
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32)> {
|
||||
blend_key_of(self.live_cursor().as_ref())
|
||||
}
|
||||
|
||||
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
|
||||
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
|
||||
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
|
||||
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
|
||||
/// blends nothing (the plain copy is the correct frame).
|
||||
/// to the pointer-less slot, which is the correct frame whenever nothing would be drawn.
|
||||
///
|
||||
/// **There is NO scratch and NO copy when the pointer is hidden or unknown.** The full-frame
|
||||
/// `CopyResource` below is the single largest cost of the composite model — a 4K FP16 ring
|
||||
/// slot is 66 MB, so at 120 fps an unconditional copy is ~8 GB/s of write bandwidth — and it
|
||||
/// buys nothing when the blend that follows draws nothing. A game that grabbed the pointer
|
||||
/// hides it, so this early-out is what makes the capture model free in the state it spends
|
||||
/// most of its life in.
|
||||
///
|
||||
/// # Safety
|
||||
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
|
||||
@@ -1274,6 +1411,18 @@ impl IddPushCapturer {
|
||||
&mut self,
|
||||
slot_tex: &ID3D11Texture2D,
|
||||
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
|
||||
// Resolve WHAT WOULD BE DRAWN first, and record it as the applied key even when that is
|
||||
// "nothing" — `try_consume`'s regen test compares against this, so an early-out must still
|
||||
// leave the key describing the frame we are about to emit. Through `live_cursor`, so a
|
||||
// dead poller degrades to the shm section here too.
|
||||
let overlay = self.live_cursor();
|
||||
self.last_blend_key = blend_key_of(overlay.as_ref());
|
||||
let ov = overlay.filter(|o| o.visible)?;
|
||||
// Blending is suppressed while a recent failure's backoff runs — skip the scratch and the
|
||||
// copy too, not just the draw: with nothing to draw onto it, the copy is pure waste.
|
||||
if self.blend_suppressed() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate
|
||||
// context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView`
|
||||
// take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before
|
||||
@@ -1325,13 +1474,7 @@ impl IddPushCapturer {
|
||||
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
|
||||
}
|
||||
None => {
|
||||
if !self.cursor_blend_failed {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!(
|
||||
"cursor blend scratch creation failed — capture-model frames stay \
|
||||
pointer-less this session"
|
||||
);
|
||||
}
|
||||
self.note_blend_failure("scratch creation failed");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1339,38 +1482,33 @@ impl IddPushCapturer {
|
||||
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
|
||||
let (tex, srv) = (tex.clone(), srv.clone());
|
||||
self.context.CopyResource(&tex, slot_tex);
|
||||
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
|
||||
// Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
|
||||
// is the path that actually draws the pointer in the composite model, and the one that
|
||||
// used to read the poller unconditionally.
|
||||
let overlay = self.live_cursor();
|
||||
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
|
||||
if let Some(ov) = overlay.filter(|o| o.visible) {
|
||||
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
|
||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
||||
Ok(p) => self.cursor_blend = Some(p),
|
||||
Err(e) => {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!(
|
||||
"cursor blend pass build failed — capture-model frames stay \
|
||||
pointer-less this session: {e:#}"
|
||||
);
|
||||
}
|
||||
// Draw `ov` — resolved and keyed at the top, where a hidden pointer already took the
|
||||
// early-out, so reaching here means there IS something to blend.
|
||||
if self.cursor_blend.is_none() {
|
||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
||||
Ok(p) => self.cursor_blend = Some(p),
|
||||
Err(e) => {
|
||||
self.note_blend_failure(&format!("blend pass build failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
|
||||
// scale it to the target's SDR white so it matches the desktop around it.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
if !self.cursor_blend_failed {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
|
||||
}
|
||||
}
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
|
||||
// scale it to the target's SDR white so it matches the desktop around it.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
// One good draw retires the whole failure record: whatever broke has healed,
|
||||
// and the next failure should get the SHORT retry, not the escalated one.
|
||||
Ok(()) => self.note_blend_success(),
|
||||
Err(e) => {
|
||||
// Drop the pass so the block above rebuilds it: a device-loss failure is
|
||||
// transient, but a pass built against the lost device never succeeds again.
|
||||
self.cursor_blend = None;
|
||||
self.note_blend_failure(&format!("blend draw failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2075,6 +2213,84 @@ mod tests {
|
||||
use super::stall::Stall;
|
||||
use super::*;
|
||||
|
||||
/// A `CursorOverlay` at `(x, y)` with `serial`, visible or not. `rgba` is never read by the
|
||||
/// key/backoff logic under test, so a 1×1 pixel keeps the fixtures honest about that.
|
||||
fn overlay(serial: u64, x: i32, y: i32, visible: bool) -> pf_frame::CursorOverlay {
|
||||
pf_frame::CursorOverlay {
|
||||
x,
|
||||
y,
|
||||
w: 1,
|
||||
h: 1,
|
||||
rgba: std::sync::Arc::new(vec![0, 0, 0, 0]),
|
||||
serial,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
visible,
|
||||
}
|
||||
}
|
||||
|
||||
/// The regen key is what would be DRAWN, so a hidden pointer keys to `None` no matter where it
|
||||
/// is. This is the whole point: a game that grabbed the pointer moves it constantly, and each
|
||||
/// of those moves used to re-encode the last slot for a frame that is pixel-identical.
|
||||
#[test]
|
||||
fn a_hidden_pointer_has_no_blend_key_wherever_it_moves() {
|
||||
assert_eq!(blend_key_of(None), None, "no overlay ⇒ nothing drawn");
|
||||
assert_eq!(
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
None,
|
||||
"hidden ⇒ nothing drawn"
|
||||
);
|
||||
assert_eq!(
|
||||
blend_key_of(Some(&overlay(7, 999, 999, false))),
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
"a hidden pointer moving must NOT look like a change"
|
||||
);
|
||||
}
|
||||
|
||||
/// …but every transition that alters the drawn frame still changes the key, or the pointer
|
||||
/// would freeze on screen (the failure mode opposite to the one above).
|
||||
#[test]
|
||||
fn every_visible_change_moves_the_blend_key() {
|
||||
let shown = blend_key_of(Some(&overlay(7, 10, 10, true)));
|
||||
assert_eq!(shown, Some((7, 10, 10)));
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(7, 11, 10, true))),
|
||||
"a visible pointer moving is a change"
|
||||
);
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(8, 10, 10, true))),
|
||||
"a new shape at the same spot is a change"
|
||||
);
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
"visible → hidden must regenerate the frame that loses the pointer"
|
||||
);
|
||||
}
|
||||
|
||||
/// The retry escalates and then holds at the ceiling — it must never grow without bound (the
|
||||
/// point of a ceiling is that a device which comes back is picked up within it).
|
||||
#[test]
|
||||
fn the_blend_retry_backoff_doubles_then_caps() {
|
||||
let first = next_blend_backoff(None);
|
||||
assert_eq!(first, BLEND_RETRY_MIN, "the first failure retries quickly");
|
||||
assert_eq!(next_blend_backoff(Some(first)), first * 2, "then doubles");
|
||||
|
||||
// Walk it well past the cap and assert it PARKS there rather than overshooting.
|
||||
let mut b = first;
|
||||
for _ in 0..32 {
|
||||
b = next_blend_backoff(Some(b));
|
||||
}
|
||||
assert_eq!(b, BLEND_RETRY_MAX, "escalation parks at the ceiling");
|
||||
assert_eq!(
|
||||
next_blend_backoff(Some(BLEND_RETRY_MAX)),
|
||||
BLEND_RETRY_MAX,
|
||||
"and stays there"
|
||||
);
|
||||
}
|
||||
|
||||
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
|
||||
///
|
||||
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
|
||||
|
||||
@@ -68,6 +68,11 @@ pub(super) struct CursorPoller {
|
||||
/// while the secure desktop needs the software-cursor path to render (see
|
||||
/// `IddPushCapturer::poll_secure_desktop`).
|
||||
secure: Arc<AtomicBool>,
|
||||
/// Monotonic count of published snapshots — the poller's HEARTBEAT. It advances once per
|
||||
/// successful poll (a failed `GetCursorInfo` `continue`s before the publish), so a thread that
|
||||
/// is wedged on an input desktop it can no longer read stops advancing this while never
|
||||
/// exiting. [`Self::alive`] cannot see that state: it only asks whether the thread finished.
|
||||
ticks: Arc<AtomicU64>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -106,10 +111,12 @@ impl CursorPoller {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let secure = Arc::new(AtomicBool::new(false));
|
||||
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
|
||||
let ticks = Arc::new(AtomicU64::new(0));
|
||||
let (slot_t, stop_t, secure_t, ticks_t) =
|
||||
(slot.clone(), stop.clone(), secure.clone(), ticks.clone());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-cursor-poll".into())
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t, &ticks_t))
|
||||
.ok();
|
||||
if thread.is_none() {
|
||||
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
|
||||
@@ -118,6 +125,7 @@ impl CursorPoller {
|
||||
slot,
|
||||
stop,
|
||||
secure,
|
||||
ticks,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
@@ -133,7 +141,14 @@ impl CursorPoller {
|
||||
self.secure.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The heartbeat count (see [`Self::ticks`]). Compared against its own previous value by the
|
||||
/// capturer — the ABSOLUTE value means nothing, only whether it is still moving.
|
||||
pub(super) fn publishes(&self) -> u64 {
|
||||
self.ticks.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
|
||||
/// Note this is liveness, NOT health: see [`Self::publishes`].
|
||||
pub(super) fn alive(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| !t.is_finished())
|
||||
}
|
||||
@@ -155,6 +170,7 @@ fn run(
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
ticks: &AtomicU64,
|
||||
) {
|
||||
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
|
||||
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
|
||||
@@ -306,6 +322,8 @@ fn run(
|
||||
}
|
||||
});
|
||||
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
|
||||
// Heartbeat AFTER the publish, so it counts snapshots the capturer can actually read.
|
||||
ticks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -656,7 +656,9 @@ impl IddPushCapturer {
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
cursor_blend_failed: false,
|
||||
blend_fail: None,
|
||||
cursor_poll_watch: (0, std::time::Instant::now()),
|
||||
cursor_poll_stalled: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
|
||||
@@ -1174,12 +1174,12 @@ pub struct Settings {
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
||||
/// why it is a device preference and never part of a settings profile. An unknown
|
||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
||||
/// palette this binary doesn't know.
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
|
||||
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
|
||||
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
|
||||
/// stream depends on it, which is why it is a device preference and never part of a
|
||||
/// settings profile. An unknown name reads as the default rather than erroring — a
|
||||
/// newer client may have shipped a palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
|
||||
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
];
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
|
||||
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
|
||||
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
|
||||
/// gain the same entry to keep the `ui_palette` key portable.
|
||||
#[rustfmt::skip]
|
||||
pub const PALETTES: [Palette; 12] = [
|
||||
pub const PALETTES: [Palette; 13] = [
|
||||
// --- dark fields (white ink) ---
|
||||
Palette {
|
||||
id: "violet", name: "Violet", stops: None,
|
||||
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is pure
|
||||
// black too: the calm mix on the form screens lifts toward nothing, so settings and
|
||||
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
|
||||
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
|
||||
// luminance while keeping the backdrop a field with somewhere to go rather than a
|
||||
// dead rectangle. The accent stays the brand violet — focus has to be findable on
|
||||
// black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: Some(&[
|
||||
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
|
||||
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
|
||||
]),
|
||||
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
@@ -857,7 +874,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"bloom", "dawn", "mint", "opal",
|
||||
]
|
||||
);
|
||||
@@ -867,7 +884,36 @@ mod tests {
|
||||
.position(|p| p.light)
|
||||
.expect("some are light");
|
||||
assert!(PALETTES[first_light..].iter().all(|p| p.light));
|
||||
assert_eq!(first_light, 6);
|
||||
assert_eq!(first_light, 7);
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
|
||||
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
|
||||
/// under every other field's, and a ground that lifts to nothing on the form screens.
|
||||
#[test]
|
||||
fn oled_is_actually_black() {
|
||||
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
|
||||
let oled = palette("oled");
|
||||
assert_eq!(
|
||||
oled.ground,
|
||||
(0.0, 0.0, 0.0),
|
||||
"the calm lift must be nothing"
|
||||
);
|
||||
let cells = oled.mesh_colors();
|
||||
assert!(
|
||||
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
|
||||
"the shaded corner has to be switched off, not dimmed"
|
||||
);
|
||||
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
|
||||
let darkest_other = PALETTES
|
||||
.iter()
|
||||
.filter(|p| p.id != "oled")
|
||||
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
|
||||
.fold(f64::MAX, f64::min);
|
||||
assert!(
|
||||
mean < darkest_other / 2.0,
|
||||
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
|
||||
|
||||
@@ -258,11 +258,17 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
||||
/// profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
|
||||
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
|
||||
/// explainer placeholder while there are none.
|
||||
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
|
||||
if self.tab != PROFILES_TAB {
|
||||
return TABS[self.tab].1.to_vec();
|
||||
return TABS[self.tab]
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_applies(*id, ctx.settings))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
vec![RowId::NoProfiles]
|
||||
@@ -271,6 +277,16 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
|
||||
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
|
||||
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
|
||||
/// take it away between frames while this screen is open.
|
||||
fn clamp_cursor(&mut self, len: usize) {
|
||||
if self.list.cursor >= len {
|
||||
self.list.jump_to(len.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tab_for_test(&self) -> usize {
|
||||
self.tab
|
||||
@@ -278,21 +294,22 @@ impl SettingsScreen {
|
||||
|
||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||
/// value cycle), keeping each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
let n = TABS.len() as i32;
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
|
||||
}
|
||||
|
||||
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
|
||||
/// a press on a pill names a tab outright rather than a direction to step in.
|
||||
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
|
||||
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
if tab >= TABS.len() {
|
||||
return None;
|
||||
}
|
||||
self.tab_cursors[self.tab] = self.list.cursor;
|
||||
self.tab = tab;
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||
let len = self.row_ids().len();
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
|
||||
// Video's follows whether the smoothness buffer is offered.
|
||||
let len = self.row_ids(ctx).len();
|
||||
self.list
|
||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||
Some(MenuPulse::Move)
|
||||
@@ -302,10 +319,11 @@ impl SettingsScreen {
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab);
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
@@ -325,11 +343,12 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -344,8 +363,14 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
// A cursor with no row under it can only mean the list shrank between the clamp above
|
||||
// and here, which nothing does today — but indexing on the assumption would turn that
|
||||
// into a panic in a shipping console rather than a dropped keypress.
|
||||
let Some(&focused) = ids.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
match focused {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
@@ -378,7 +403,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(focused, delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -388,7 +413,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(focused, 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -397,8 +422,8 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids();
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
hints.extend(match ids.get(self.list.cursor) {
|
||||
@@ -445,7 +470,8 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
@@ -466,6 +492,24 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
|
||||
///
|
||||
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
|
||||
/// can see a line or two above them, so dimming them shows the relationship — dropping them
|
||||
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
|
||||
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
|
||||
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
|
||||
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
|
||||
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
|
||||
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
|
||||
/// so the cursor is never under anything that moves.
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
||||
// move everything under the cursor).
|
||||
// Two rows follow a switch a line or two above them: echo cancellation only means
|
||||
// anything while the mic streams, and the pad rows only while any controller is
|
||||
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
|
||||
// shells draw by greying a row out, and dimming (not dropping) is what shows the
|
||||
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
|
||||
// list instead now — see [`row_applies`] for why that one is different.
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
|
||||
// is only reachable if another writer flipped the intent between the frame that built
|
||||
// the list and the keypress that lands here — a boundary thud, not a stored value
|
||||
// nothing will read.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
@@ -1093,9 +1139,6 @@ mod tests {
|
||||
fake_home();
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
rendered(&mut s);
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids()[0], RowId::Resolution);
|
||||
let first = s.list.row_rect(0).expect("the list drew its rows");
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
|
||||
@@ -1109,6 +1152,9 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
|
||||
let mut fx = Outbox::default();
|
||||
assert!(!ctx.settings.match_window);
|
||||
assert!(s.pointer(press(first), &mut ctx, &mut fx));
|
||||
@@ -1232,13 +1278,12 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||
/// always done; this screen was the exception until its row list stopped being fixed.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
fn smoothness_buffer_is_offered_only_under_smoothness() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
@@ -1251,24 +1296,93 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(
|
||||
!video.contains(&RowId::SmoothBuffer),
|
||||
"latency hides the buffer row: {video:?}"
|
||||
);
|
||||
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
|
||||
// Even reached out of band it writes nothing — the list it came from is a frame old.
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
// Stepping the intent to Smoothness brings the row into the list, directly under it.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
let intent = video
|
||||
.iter()
|
||||
.position(|id| *id == RowId::PresentPriority)
|
||||
.expect("the intent row");
|
||||
assert_eq!(
|
||||
video.get(intent + 1),
|
||||
Some(&RowId::SmoothBuffer),
|
||||
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
|
||||
never has anything move out from under it"
|
||||
);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
// The intent wraps back and the row leaves again — with the cursor parked on the
|
||||
// intent row, which is where a user who just stepped it necessarily is.
|
||||
s.list.cursor = intent;
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(!video.contains(&RowId::SmoothBuffer));
|
||||
assert_eq!(
|
||||
video.get(s.list.cursor),
|
||||
Some(&RowId::PresentPriority),
|
||||
"the cursor is still on the row the user was stepping"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
|
||||
/// than indexed with — the console must not panic because another writer changed the
|
||||
/// presentation intent while its settings screen was open.
|
||||
#[test]
|
||||
fn a_shrinking_list_pulls_the_cursor_back() {
|
||||
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
|
||||
// seated with the SHRUNKEN list's intent, which is the state being tested.
|
||||
fake_home();
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.present_priority = "latency".into();
|
||||
settings.save();
|
||||
settings.present_priority = "smooth".into();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
// Park on the last row while the buffer row is still there…
|
||||
s.list.cursor = s.row_ids(&ctx).len() - 1;
|
||||
let parked = s.list.cursor;
|
||||
// …then take it away behind the screen's back, as a desktop shell would.
|
||||
ctx.settings.present_priority = "latency".into();
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(pulse.is_some(), "the press was routed, not dropped");
|
||||
assert!(s.list.cursor < parked, "the cursor came back onto the list");
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1392,7 +1506,7 @@ mod tests {
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
@@ -1438,7 +1552,7 @@ mod tests {
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert!(!spec.enabled);
|
||||
|
||||
@@ -329,7 +329,7 @@ fn dump_console_screens() {
|
||||
for _ in 0..5 {
|
||||
s.handle_menu(MenuEvent::JumpForward);
|
||||
}
|
||||
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
s.settings.ui_palette = id.to_string();
|
||||
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
|
||||
}
|
||||
|
||||
@@ -421,6 +421,34 @@ pub fn hw_cursor_capable() -> bool {
|
||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||
}
|
||||
|
||||
/// Is NO session currently streaming to a virtual display?
|
||||
///
|
||||
/// The safety question for anything that tears the adapter down — notably
|
||||
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` takes every
|
||||
/// monitor on the adapter with it. Only [`SlotState::Active`] counts: that is a session with live
|
||||
/// references, and destroying its monitor mid-stream is the cross-session damage worth refusing.
|
||||
///
|
||||
/// `Lingering`/`Pinned` slots deliberately do NOT count. They are keep-alive monitors with no
|
||||
/// session attached, and a reconnect **already** preempts and recreates them — "a reused IddCx
|
||||
/// swap-chain is dead" (see [`SlotState::Pinned`]) — so a device restart destroys nothing the
|
||||
/// reconnect was not going to destroy anyway. Counting them was too conservative to be useful: the
|
||||
/// case this gate exists for is exactly *disconnect from a desktop session, reconnect in capture
|
||||
/// mode*, and the disconnected session's monitor is lingering at precisely that moment, so the
|
||||
/// clean-up could never fire when it was most wanted (observed on `.173`, 2026-08-08).
|
||||
pub fn no_active_sessions() -> bool {
|
||||
match VDM.get() {
|
||||
// Before the first backend open there is nothing to protect.
|
||||
None => true,
|
||||
Some(m) => !m
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.slots
|
||||
.values()
|
||||
.any(|s| matches!(s, SlotState::Active { .. })),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_device_handle() -> Option<HANDLE> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,226 @@ enum AdapterCycle {
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Restart the pf-vdisplay device to CLEAR a sticky IddCx hardware-cursor declare, so sessions that
|
||||
/// do not want the host to own the pointer get the OS's own cursor compositing back (full fidelity,
|
||||
/// zero host cost — no GDI poller, no per-frame blend, true XOR instead of our outline
|
||||
/// approximation).
|
||||
///
|
||||
/// **Why this exists.** A hardware-cursor declare is irrevocable and ADAPTER-WIDE
|
||||
/// (`pf-driver-proto` v6 note): once any desktop-mode session declares, DWM stops compositing the
|
||||
/// pointer into EVERY later frame on that adapter, and every subsequent session — including
|
||||
/// capture-latched ones that never asked for a cursor channel — has to self-composite. The state
|
||||
/// lives in the driver's `DECLARED_TARGETS`, whose scope is the WUDFHost process, so recycling that
|
||||
/// process clears it.
|
||||
///
|
||||
/// **Why `/restart-device` and not the [`reload_vdisplay_adapter`] cycle.** Measured on-glass
|
||||
/// 2026-08-08 (`.173`): `pnputil /restart-device` returned in **0.07 s** with a NEW WUDFHost pid,
|
||||
/// against ~6 s of sleeps for `Disable`+`Enable` — and, being designed for a device that is in use,
|
||||
/// it does not hit the refusal that doc calls "the expected case here". It also repaired an adapter
|
||||
/// found in `CM_PROB_FAILED_POST_START` (Code 43) in the same call.
|
||||
///
|
||||
/// ⚠⚠ **This is a ONCE-PER-BOOT lever, not a cheap one.** Measured on `.173` 2026-08-08: the first
|
||||
/// `/restart-device` after a cold boot succeeds in 0.07 s; every later one in the same boot fails
|
||||
/// with *"Das System muss neu gestartet werden, damit Konfigurationsvorgänge abgeschlossen
|
||||
/// werden"*, and repeated attempts additionally push the devnode into `restart pending`. So this
|
||||
/// can clean the adapter at host start-up and nowhere else — anything wanting to un-declare
|
||||
/// mid-boot (e.g. giving a capture session back the lossless pointer after a desktop session) needs
|
||||
/// a different mechanism to recycle the driver's WUDFHost process, which is where the declare
|
||||
/// actually lives.
|
||||
///
|
||||
/// ⚠ It tears the adapter down, so it must run only when NO session holds a display — the host
|
||||
/// start-up path. `PUNKTFUNK_CURSOR_CLEAN_START=0` disables it.
|
||||
///
|
||||
/// Returns `true` only when pnputil reported success. Best-effort: a failure just leaves the
|
||||
/// adapter as it was (sessions then self-composite exactly as before).
|
||||
/// The driver's WUDFHost pid, from the most recent ADD reply. `0` before any monitor was created.
|
||||
static LAST_WUDF_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Clear a sticky hardware-cursor declare by recycling the driver's WUDFHost process.
|
||||
///
|
||||
/// The declare is irrevocable and adapter-wide, but its scope is the WUDFHost process
|
||||
/// (`monitor.rs` `DECLARED_TARGETS`) — so killing that process drops it. WUDF respawns the host on
|
||||
/// the next open, with a fresh adapter object.
|
||||
///
|
||||
/// **This is what makes un-declaring possible mid-boot.** `pnputil /restart-device` also works but
|
||||
/// is a ONCE-PER-BOOT operation (see [`restart_device_for_clean_cursor`]); the start-up clean
|
||||
/// spends it, leaving nothing for the desktop-session→reconnect case. Measured on `.173`
|
||||
/// 2026-08-08: pid 3872 → 19932, `adapter_luid` 0x8ed607 → 0x1a8f6ca, `cursor_excluded` true →
|
||||
/// **false**, next session streamed normally.
|
||||
///
|
||||
/// Same precondition as the device restart: no session may hold a display, because every monitor
|
||||
/// on the adapter dies with the host.
|
||||
fn recycle_wudfhost() -> bool {
|
||||
let pid = LAST_WUDF_PID.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if pid == 0 {
|
||||
tracing::info!("cursor: no driver host pid known yet — nothing to recycle");
|
||||
return false;
|
||||
}
|
||||
// taskkill rather than OpenProcess/TerminateProcess: the host runs as SYSTEM, so it already has
|
||||
// the rights, and shelling out keeps this off the unsafe-proof budget for a once-per-session
|
||||
// maintenance action.
|
||||
match std::process::Command::new(
|
||||
std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32 askkill.exe"))
|
||||
.unwrap_or_else(|_| "taskkill.exe".to_string()),
|
||||
)
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!(
|
||||
pid,
|
||||
"cursor: recycled the driver's WUDFHost — the hardware-cursor declare is gone"
|
||||
);
|
||||
LAST_WUDF_PID.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
pid,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr).trim().replace('\n', " "),
|
||||
"cursor: could not recycle the driver's WUDFHost — this session self-composites"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(pid, error = %e, "cursor: taskkill spawn failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this host process DECLARED an IddCx hardware cursor since the adapter was last restarted?
|
||||
/// Set by the ADD path when a session ASKS for a hardware cursor (the one place every declare
|
||||
/// passes through); cleared when the declare is dropped. The host's own mirror of the
|
||||
/// driver's `DECLARED_TARGETS` — cheaper than probing, and it only ever needs to be right about
|
||||
/// "did WE dirty it", because a declare from an earlier BOOT is handled by the start-up clean.
|
||||
static CURSOR_DECLARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Give the NEXT session back the lossless cursor: if an earlier session on this host declared the
|
||||
/// hardware cursor and this one does not want it, restart the device to clear the sticky declare.
|
||||
///
|
||||
/// This is the case the start-up clean cannot reach — **run a desktop-mode session, disconnect,
|
||||
/// reconnect in capture mode**. Same host process, so the adapter is still dirty from the first
|
||||
/// session and the capture session would self-composite the pointer for its whole life. Declaring
|
||||
/// is one-way and adapter-wide (`pf-driver-proto` v6), so the only way back is a device restart —
|
||||
/// 0.07 s, measured.
|
||||
///
|
||||
/// Must be called BEFORE this session creates its display, and only when nothing else holds one:
|
||||
/// the restart takes every monitor on the adapter with it.
|
||||
///
|
||||
/// Returns `true` only when it actually restarted.
|
||||
pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
if session_wants_declare || !CURSOR_DECLARED.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
// Gated deliberately — a device restart is NOT free. Windows puts the devnode into
|
||||
// "restart pending" after repeated cycles, and `/restart-device` then refuses with "a system
|
||||
// restart is pending for this device" until an actual reboot (hit on .173 2026-08-08 after ~6
|
||||
// restarts in one afternoon, which is also what made the earlier runs look like a wiring bug:
|
||||
// the call ran, the restart failed, and nothing logged the failure). So restart only when a
|
||||
// declare is actually outstanding, never speculatively.
|
||||
let previously_declared = true;
|
||||
// Refuse only while another session is STREAMING — a keep-alive (lingering/pinned) monitor has
|
||||
// no session attached and a reconnect recreates it regardless, so restarting the adapter costs
|
||||
// it nothing. Gating on keep-alive too made this dead code in the one case it exists for: after
|
||||
// a desktop session disconnects its monitor LINGERS, which is exactly when the next
|
||||
// capture-mode connect needs the declare gone (observed on .173).
|
||||
if !super::manager::no_active_sessions() {
|
||||
tracing::info!(
|
||||
"cursor: this session wants no hardware cursor and an earlier one declared, but a display is still held (live or keep-alive) — skipping the adapter restart, so the pointer stays host-composited for this session"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if recycle_wudfhost() {
|
||||
// The cached control handle died with the host process. Retire it so the next
|
||||
// `ensure_device` reopens against the respawned WUDFHost — without this the ADD that
|
||||
// follows runs on a stale handle and the session comes up with no frames at all.
|
||||
super::manager::invalidate_cached_device("cursor clean: recycled the driver host");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
CURSOR_DECLARED.store(false, Ordering::Relaxed);
|
||||
tracing::info!(
|
||||
previously_declared,
|
||||
"cursor: restarted the adapter for this capture-mode session — any hardware-cursor \
|
||||
declare is gone, so the OS composites the pointer itself (full fidelity, no host \
|
||||
blend). previously_declared=false only means the host-side hint was unset; the \
|
||||
restart is idempotent either way"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn restart_device_for_clean_cursor() -> bool {
|
||||
if std::env::var("PUNKTFUNK_CURSOR_CLEAN_START").is_ok_and(|v| v == "0") {
|
||||
tracing::info!(
|
||||
"pf-vdisplay: cursor clean-start disabled (PUNKTFUNK_CURSOR_CLEAN_START=0) — a sticky \
|
||||
hardware-cursor declare from an earlier boot will keep sessions self-compositing"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// `$LASTEXITCODE` is pre-seeded to 1 for the same reason `reload_vdisplay_adapter` does it: if
|
||||
// pnputil never launches, a stale value must not read as success.
|
||||
const PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { $out = (& $pnp /restart-device $ad.InstanceId 2>&1 | Out-String) }; \
|
||||
if ($LASTEXITCODE -eq 0) { Write-Output 'RESTARTED' } \
|
||||
else { Write-Output ('FAILED ' + ($out -replace '\\s+', ' ')) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
let out = match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
PS,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: cursor clean-start could not spawn powershell");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
match out.as_str() {
|
||||
"RESTARTED" => {
|
||||
tracing::info!(
|
||||
"pf-vdisplay: restarted the adapter at start-up — any sticky hardware-cursor \
|
||||
declare is cleared, so sessions without a cursor channel get the OS's own \
|
||||
(full-fidelity, zero-cost) pointer compositing until one declares again"
|
||||
);
|
||||
true
|
||||
}
|
||||
"ABSENT" => false, // driver not installed — nothing to clean, and `open` reports that later
|
||||
// Keep pnputil's own text. The failure that actually occurs is "a system restart is
|
||||
// pending for this device" — no retry fixes it, and a bare exit code hid it for three runs.
|
||||
other => {
|
||||
tracing::warn!(
|
||||
outcome = other,
|
||||
// Two distinct wordings, both meaning "not until you reboot":
|
||||
// "Für das Gerät steht ein Systemneustart aus" (device restart pending)
|
||||
// "Das System muss neu gestartet werden, damit …" (config ops need a reboot)
|
||||
// The second is what you actually hit, and it appears after the FIRST successful
|
||||
// restart of a boot — see the doc on `restart_device_for_clean_cursor`.
|
||||
needs_reboot = other.contains("Systemneustart")
|
||||
|| other.contains("muss neu gestartet werden")
|
||||
|| other.to_ascii_lowercase().contains("restart is pending")
|
||||
|| other.to_ascii_lowercase().contains("must be restarted"),
|
||||
"pf-vdisplay: cursor clean-start did not restart the adapter — sessions without a \
|
||||
cursor channel will self-composite the pointer if an earlier declare is sticky"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
|
||||
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
|
||||
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
|
||||
@@ -353,6 +573,12 @@ pub unsafe fn send_cursor_channel(
|
||||
dev: HANDLE,
|
||||
req: &control::SetCursorChannelRequest,
|
||||
) -> Result<()> {
|
||||
// THE declare point. The driver declares its IddCx hardware cursor when this channel arrives —
|
||||
// not from the ADD request's `hw_cursor` flag, which is why recording the declare there (and,
|
||||
// before that, in `capture_virtual_output`) left the flag false and the between-session clean
|
||||
// silently inert. The log line that names this moment is "cursor channel delivered - driver
|
||||
// declares the hardware cursor".
|
||||
CURSOR_DECLARED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per this fn's contract `dev` is the live control handle; `bytes_of(req)` borrows the
|
||||
// caller's request across this synchronous call; no output buffer.
|
||||
@@ -679,6 +905,26 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hw_cursor: bool,
|
||||
) -> Result<AddedMonitor> {
|
||||
// Give a capture-mode session the LOSSLESS pointer back: if an earlier session declared a
|
||||
// hardware cursor and this one does not want it, recycle the driver's host process BEFORE
|
||||
// this monitor is added. The ADD path is the only place guaranteed to see every session
|
||||
// (the handshake call site this replaced sat in a `match (source, compositor)` arm that is
|
||||
// not taken on this host, so it never ran).
|
||||
// ⚠ DISABLED BY DEFAULT — opt in with PUNKTFUNK_CURSOR_RECYCLE=1.
|
||||
//
|
||||
// The MECHANISM is proven (recycling the driver host clears the declare: measured pid
|
||||
// 3872→19932, adapter_luid 0x8ed607→0x1a8f6ca, cursor_excluded true→false, next session
|
||||
// streamed fine). What is NOT solved is calling it from HERE: `invalidate_cached_device`
|
||||
// takes the manager `device` mutex, which this ADD path already holds, so the session
|
||||
// DEADLOCKS — observed on .173, the ADD stops after SET_RENDER_ADAPTER and the client gets
|
||||
// "no frames received". Its own doc warns about exactly this.
|
||||
//
|
||||
// The fix is a call site that runs OUTSIDE the mutex and still on every session's path;
|
||||
// the handshake site tried before is not reached on this host. Until then this stays off:
|
||||
// a session that self-composites is the old behaviour, a deadlocked one is a regression.
|
||||
if !hw_cursor && std::env::var("PUNKTFUNK_CURSOR_RECYCLE").is_ok_and(|v| v == "1") {
|
||||
clean_cursor_for_next_session(false);
|
||||
}
|
||||
let session_id = next_session_id();
|
||||
// The client display's volume rides into the monitor's EDID CTA HDR block; all-zero =
|
||||
// unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which
|
||||
@@ -824,7 +1070,14 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
tracing::info!(
|
||||
target_id = reply.target_id,
|
||||
adapter_luid = %format_args!("{:#x}", luid.LowPart),
|
||||
wudf_pid = reply.wudf_pid,
|
||||
wudf_pid = {
|
||||
// The declare lives in THIS process (monitor.rs `DECLARED_TARGETS`), so remember it:
|
||||
// recycling it is the only way to un-declare that does not cost the once-per-boot
|
||||
// device restart. Proven on .173 2026-08-08 — killing it gave a new host pid, a NEW
|
||||
// adapter luid, and `cursor_excluded=false`, with the next session streaming fine.
|
||||
LAST_WUDF_PID.store(reply.wudf_pid, std::sync::atomic::Ordering::Relaxed);
|
||||
reply.wudf_pid
|
||||
},
|
||||
cursor_excluded = reply.cursor_excluded != 0,
|
||||
"pf-vdisplay monitor created {}x{}@{}",
|
||||
mode.width,
|
||||
|
||||
@@ -382,6 +382,23 @@ fn real_main() -> Result<()> {
|
||||
// driver to a stray second host started while the service sat idle.
|
||||
#[cfg(target_os = "windows")]
|
||||
vdisplay::manager::claim_instance_eagerly();
|
||||
// Clean-cursor start (design/windows-cursor-model-determinism.md §4.3): clear any
|
||||
// sticky IddCx hardware-cursor declare left on the adapter by an EARLIER boot's
|
||||
// desktop-mode session. That declare is irrevocable and adapter-wide, so without this
|
||||
// every capture-latched session on the box self-composites the pointer for the rest of
|
||||
// the adapter's life — paying a full-frame copy per visible-pointer frame and drawing
|
||||
// our straight-alpha approximation of an XOR cursor — when the OS would otherwise
|
||||
// composite it natively, for free, at full fidelity.
|
||||
//
|
||||
// It is NOT enough to wait for a reboot: with Fast Startup on (the Windows default) a
|
||||
// shutdown+power-on is a hiberboot that RESTORES session 0 and its drivers, so the
|
||||
// declare survives what the operator calls a reboot (measured: Kernel-Boot event id 27
|
||||
// `0x1`, and `lsass`/`services` keeping their pre-"reboot" start times). Only a cold
|
||||
// boot or a device restart actually clears it — and the device restart costs 0.07 s.
|
||||
//
|
||||
// Runs HERE, before any session holds a display: the restart tears the adapter down.
|
||||
#[cfg(target_os = "windows")]
|
||||
vdisplay::driver::restart_device_for_clean_cursor();
|
||||
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
|
||||
// monitor devnodes a previous host disabled for an Exclusive session and never
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
|
||||
@@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps
|
||||
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
|
||||
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
|
||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
|
||||
**Prioritize** is offered, and greyed out until you pick Smoothness.
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
|
||||
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
|
||||
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
|
||||
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
|
||||
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
|
||||
frames for it to count, so it isn't shown at all.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
||||
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
||||
@@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
and Android have no equivalent.
|
||||
|
||||
## Interface
|
||||
|
||||
These change how the client itself looks and behaves. None of them touches a stream, so none of them
|
||||
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
|
||||
you.
|
||||
|
||||
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
|
||||
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
|
||||
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
|
||||
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
|
||||
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
|
||||
turn off. An Android TV is always in this mode — its remote is the only input it has.
|
||||
|
||||
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
|
||||
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
|
||||
controller-optimized home appears as a pad connects and the touch interface returns when the last one
|
||||
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
|
||||
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
|
||||
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
|
||||
there.)
|
||||
|
||||
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
|
||||
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
|
||||
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
|
||||
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
|
||||
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
|
||||
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
|
||||
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
|
||||
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
|
||||
alike. Appearance only — nothing about a stream depends on it.
|
||||
|
||||
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
|
||||
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
|
||||
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
|
||||
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
|
||||
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
|
||||
|
||||
## Overlay
|
||||
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
@@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**:
|
||||
profile forwards.
|
||||
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
|
||||
not about how a given host is streamed.
|
||||
- Everything under **Interface** — **Gamepad-optimized browsing**, **Show it** and **Background**.
|
||||
How this client looks and which layout it wears has nothing to do with how a host streams to it,
|
||||
so binding them to a host would only make the same device change appearance depending on what it
|
||||
connected to.
|
||||
|
||||
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
|
||||
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
|
||||
|
||||
Reference in New Issue
Block a user