Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
3b39710a5a | ||
|
|
19243c30b4 | ||
|
|
dfcffcdd50 | ||
|
|
f2b5b3e567 | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
675030935a | ||
|
|
0e5a059098 | ||
|
|
245173a731 |
@@ -537,6 +537,55 @@ legs as follow-ups. Both landed here.
|
||||
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
|
||||
upload rides the client's own mTLS.
|
||||
|
||||
### A provider plugin can report which of its titles are **running**
|
||||
|
||||
New: `PUT /api/v1/library/provider/{provider}/running`, body
|
||||
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
|
||||
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
|
||||
running now*, and carries the pid where the provider knows one. Additive: no existing route,
|
||||
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
|
||||
|
||||
It exists because one class of title could never be tracked at all. The host derives liveness by
|
||||
scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install
|
||||
directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one,
|
||||
or a library plugin that records no install directory has none of that, and its launch is a
|
||||
`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit
|
||||
was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim
|
||||
at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured,
|
||||
and fires an event on both edges carrying the pid. That was being thrown away.
|
||||
|
||||
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's
|
||||
**complete** running set, so a missed event, a plugin restart or an install mid-game self-correct
|
||||
on the next report instead of drifting. Absent from the set = stopped.
|
||||
- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what
|
||||
makes it safe for a live provider to hold a streaming session open for a game the host cannot
|
||||
see: a plugin that dies with a game running stops counting shortly after and the host falls back
|
||||
to scanning. Reporters must restate well inside the window.
|
||||
- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by
|
||||
what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id;
|
||||
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
|
||||
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
|
||||
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
|
||||
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
|
||||
provider report is decisive in both directions. A reported pid joins the termination ladders on
|
||||
the same terms as a spawned one (re-resolved and start-time-pinned at the moment of use).
|
||||
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
|
||||
exhaustive classification table). No new authority — the host maps `external_id` through the
|
||||
catalog, so a provider can only ever speak about entries it published; an unknown id is *counted*,
|
||||
not refused, because a report legitimately races its own reconcile and 400-ing the batch would
|
||||
throw away the liveness of every other running title.
|
||||
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
|
||||
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
|
||||
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
|
||||
|
||||
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
|
||||
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
|
||||
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
|
||||
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
|
||||
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
|
||||
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
|
||||
carrying this route; an older one 404s and the plugin carries on without it.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
|
||||
@@ -1860,6 +1860,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7792,6 +7855,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +7949,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
// The CORRECTED keycode, so this screen shows the button the stream will send and not
|
||||
// the one Android guessed for a pad it has no key layout for — the two differ on every
|
||||
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
|
||||
// stream is worse than no tester. The raw pair is still reported in "Last input".
|
||||
val code = Gamepad.padKeyCode(event)
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
held[code] = true
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
held[code] = false
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
|
||||
// field report needs to pin an unmapped pad's report order without the device in hand.
|
||||
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
|
||||
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
|
||||
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
|
||||
event.scanCode,
|
||||
raw,
|
||||
if (code != event.keyCode) " → $fixed" else "",
|
||||
)
|
||||
consume
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
|
||||
// streaming — on a pad Android has no key layout for, the right stick and the triggers
|
||||
// are not on the axes their names suggest.
|
||||
val map = Gamepad.padMap(event.device)
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["RX"] = event.getAxisValue(map.rightStickX)
|
||||
axes["RY"] = event.getAxisValue(map.rightStickY)
|
||||
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.leftTrigger))
|
||||
}
|
||||
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.rightTrigger))
|
||||
}
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only when a correction is actually in force: on a pad Android has a key layout for
|
||||
// there is nothing to say, and a line that says "normal" on every device teaches
|
||||
// nobody anything. Named rather than merely flagged, so a field report can quote it.
|
||||
padButtonsNote(info.buttons)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -784,6 +823,12 @@ internal data class PadInfo(
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
/**
|
||||
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
|
||||
* the pad Android already knows, which is what a screenshot scene wants and what the note
|
||||
* under the card stays silent about.
|
||||
*/
|
||||
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
/**
|
||||
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
|
||||
* has no key layout for it — null for a pad it does know, which needs no explanation.
|
||||
*/
|
||||
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
|
||||
Gamepad.PadButtons.NATIVE -> null
|
||||
Gamepad.PadButtons.GENERIC_SONY ->
|
||||
"Android has no button layout for this controller — read as a PlayStation pad"
|
||||
Gamepad.PadButtons.GENERIC_XBOX ->
|
||||
"Android has no button layout for this controller — read as an Xbox pad"
|
||||
Gamepad.PadButtons.SONY_MODERN ->
|
||||
"Android has no button layout for this controller — face buttons corrected"
|
||||
}
|
||||
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
|
||||
@@ -616,7 +616,7 @@ class MainActivity : ComponentActivity() {
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -708,8 +708,10 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
|
||||
// menus on the right buttons too, not only the stream.
|
||||
when (Gamepad.padKeyCode(event)) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
|
||||
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -317,15 +317,20 @@ internal object ConsoleJson {
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
extra.put("android.low_latency", s.lowLatencyMode)
|
||||
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
extra.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
extra.put("android.sc2_capture", s.sc2Capture)
|
||||
extra.put("android.ds_capture", s.dsCapture)
|
||||
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("extra", extra)
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -335,7 +340,8 @@ internal object ConsoleJson {
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
@@ -372,13 +378,14 @@ internal object ConsoleJson {
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ object SkiaConsole {
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
|
||||
@@ -159,7 +159,12 @@ fun SkiaConsoleShell(
|
||||
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
|
||||
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (fromPad) {
|
||||
val bit = when (ev.keyCode) {
|
||||
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
|
||||
// under other buttons' names, so read raw this console answered ✕ with whatever
|
||||
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
|
||||
// and the game must not disagree about which button a user pressed.
|
||||
val code = Gamepad.padKeyCode(ev)
|
||||
val bit = when (code) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> 0
|
||||
KeyEvent.KEYCODE_BUTTON_B -> 1
|
||||
KeyEvent.KEYCODE_BUTTON_X -> 2
|
||||
@@ -179,7 +184,7 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
return@probe true
|
||||
}
|
||||
val dbit = when (ev.keyCode) {
|
||||
val dbit = when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 1
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> 2
|
||||
@@ -191,7 +196,7 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
return@probe true
|
||||
}
|
||||
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
|
||||
return@probe true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -127,8 +128,12 @@ object Gamepad {
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
|
||||
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
|
||||
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
|
||||
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -188,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -293,6 +342,303 @@ object Gamepad {
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controllers Android has no key layout for
|
||||
//
|
||||
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
|
||||
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
|
||||
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
|
||||
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
|
||||
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
|
||||
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
|
||||
// ORDER — so every keycode after the first divergence is somebody else's button.
|
||||
//
|
||||
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
|
||||
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
|
||||
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
|
||||
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
|
||||
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
|
||||
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
|
||||
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
|
||||
// symptom exactly.
|
||||
//
|
||||
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
|
||||
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
|
||||
// force and already knows this pad better than we do, so we leave it alone.
|
||||
// 2. Which report order to read is decided from what the DEVICE declares, never a model
|
||||
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
|
||||
// keycodes no real controller has a button for.
|
||||
//
|
||||
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
|
||||
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
|
||||
/**
|
||||
* The report order a controller's buttons are numbered in, and with it which scancode carries
|
||||
* which physical button. Resolved once per device by [padButtons] from what the device
|
||||
* declares; [correct] then maps one scancode to the keycode it should have produced.
|
||||
*/
|
||||
enum class PadButtons {
|
||||
/**
|
||||
* The keycode Android delivered is already right — a device-specific key layout is in
|
||||
* force, or the generic one happens to agree. [correct] changes nothing.
|
||||
*/
|
||||
NATIVE,
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
/**
|
||||
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
|
||||
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
|
||||
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
|
||||
*/
|
||||
GENERIC_XBOX,
|
||||
|
||||
/**
|
||||
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
|
||||
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
|
||||
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
|
||||
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
|
||||
* face pair comes out swapped and nothing else is wrong.
|
||||
*/
|
||||
SONY_MODERN,
|
||||
;
|
||||
|
||||
/**
|
||||
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
|
||||
*
|
||||
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
|
||||
* said for [scan] — anything else is a device-specific layout's answer, which outranks
|
||||
* this table. That guard is what makes the correction idempotent and safe to run on
|
||||
* every pad: it can only ever fire where Android was guessing in the first place.
|
||||
*/
|
||||
fun correct(scan: Int, keyCode: Int): Int {
|
||||
if (this == NATIVE) return keyCode
|
||||
if (keyCode != genericKeyCode(scan)) return keyCode
|
||||
val fixed = when (this) {
|
||||
GENERIC_SONY -> when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> keyCode // 0x130 A / 0x131 B already agree
|
||||
}
|
||||
// Only the face pair; every other row of Generic.kl is right for these codes.
|
||||
SONY_MODERN -> when (scan) {
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
|
||||
else -> keyCode
|
||||
}
|
||||
NATIVE -> keyCode
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
|
||||
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
|
||||
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
|
||||
* events alone.
|
||||
*/
|
||||
fun genericKeyCode(scan: Int): Int = when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_A
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_B
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_C
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_START
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* How one controller must be read: its button report order plus the axes its right stick and
|
||||
* analog triggers actually arrive on. Resolved once per device by [padMap].
|
||||
*/
|
||||
class PadMap(
|
||||
val buttons: PadButtons,
|
||||
val rightStickX: Int = MotionEvent.AXIS_Z,
|
||||
val rightStickY: Int = MotionEvent.AXIS_RZ,
|
||||
/**
|
||||
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
|
||||
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
|
||||
* report one pair, the other, or both have always needed.
|
||||
*/
|
||||
val leftTrigger: Int = AXIS_NONE,
|
||||
val rightTrigger: Int = AXIS_NONE,
|
||||
/** Those trigger axes rest at −1 rather than 0, measured off the device's own range. */
|
||||
val triggersSigned: Boolean = false,
|
||||
) {
|
||||
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
|
||||
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
|
||||
}
|
||||
|
||||
/** The map every pad with a key layout uses: Android's own names, unchanged. */
|
||||
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
|
||||
|
||||
/**
|
||||
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
|
||||
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
|
||||
* Nothing here depends on a live connection, so entries never need evicting.
|
||||
*/
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
|
||||
*
|
||||
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
|
||||
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
|
||||
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
|
||||
* Declaring the pair is therefore the signature of a pad Android is guessing at.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
val straightThrough = has[0] && has[1]
|
||||
return when {
|
||||
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
|
||||
straightThrough -> PadButtons.GENERIC_XBOX
|
||||
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
|
||||
* arrive on, resolved once per device model and cached.
|
||||
*
|
||||
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
|
||||
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
|
||||
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
|
||||
* raw axes under the names the HID report gave them. Which two depends on the same report
|
||||
* order the buttons did:
|
||||
*
|
||||
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
|
||||
* stick, then the triggers — so the right stick is already right and only the triggers
|
||||
* (`AXIS_RX`/`AXIS_RY`) are missed;
|
||||
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
|
||||
* the shape that makes pulling a trigger swing the right stick.
|
||||
*
|
||||
* Whether those axes idle at −1 is MEASURED from the device's own range rather than assumed,
|
||||
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
|
||||
*/
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
val buttons = padButtons(dev)
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
|
||||
(rx?.min ?: 0f) < -0.5f
|
||||
} else {
|
||||
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
|
||||
}
|
||||
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
|
||||
padMaps[dev.descriptor] = map
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half of [padMap], decided from four facts about the device so it can be pinned
|
||||
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
|
||||
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
|
||||
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
|
||||
* whichever axis pair the fallback picks, never assumed.
|
||||
*/
|
||||
fun padMap(
|
||||
buttons: PadButtons,
|
||||
namedTriggers: Boolean,
|
||||
hasRxRy: Boolean,
|
||||
restsNegative: Boolean,
|
||||
): PadMap = when {
|
||||
namedTriggers || !hasRxRy -> PadMap(buttons)
|
||||
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
|
||||
buttons == PadButtons.GENERIC_SONY -> PadMap(
|
||||
buttons,
|
||||
leftTrigger = MotionEvent.AXIS_RX,
|
||||
rightTrigger = MotionEvent.AXIS_RY,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
|
||||
// right stick makes pulling a trigger swing it.
|
||||
else -> PadMap(
|
||||
buttons,
|
||||
rightStickX = MotionEvent.AXIS_RX,
|
||||
rightStickY = MotionEvent.AXIS_RY,
|
||||
leftTrigger = MotionEvent.AXIS_Z,
|
||||
rightTrigger = MotionEvent.AXIS_RZ,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
}
|
||||
|
||||
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
|
||||
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
|
||||
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
|
||||
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
|
||||
|
||||
/**
|
||||
* The keycode [event] should have carried, given the controller it came from — [event]'s own
|
||||
* keycode for every pad Android has a key layout for, and the scancode's true button for one
|
||||
* it does not (see the block comment above [PadButtons]).
|
||||
*
|
||||
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
|
||||
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
|
||||
* pad is fixed in the menus and in the game at once. Events from anything that is not a
|
||||
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
|
||||
* untouched.
|
||||
*/
|
||||
fun padKeyCode(event: KeyEvent): Int {
|
||||
val dev = event.device ?: return event.keyCode
|
||||
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
|
||||
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
@@ -306,7 +652,12 @@ object Gamepad {
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
class AxisMapper(
|
||||
private val handle: Long,
|
||||
private val pad: Int,
|
||||
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
|
||||
private val map: PadMap = NATIVE_MAP,
|
||||
) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
@@ -317,30 +668,18 @@ object Gamepad {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
|
||||
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
|
||||
// reporting NONE of those names is one Android has no key layout for, and [map]
|
||||
// carries the raw axes its triggers really landed on instead.
|
||||
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
|
||||
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
|
||||
sendAxis(AXIS_LT, trigger(lt))
|
||||
sendAxis(AXIS_RT, trigger(rt))
|
||||
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
@@ -383,6 +722,17 @@ object Gamepad {
|
||||
hatY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
|
||||
* else the max of the two names Android gives a trigger it does know.
|
||||
*/
|
||||
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
|
||||
if (resolvedAxis == AXIS_NONE) {
|
||||
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
|
||||
} else {
|
||||
map.level(event.getAxisValue(resolvedAxis))
|
||||
}
|
||||
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
|
||||
@@ -605,7 +605,7 @@ class GamepadRouter(
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
|
||||
@@ -298,6 +298,18 @@ object NativeBridge {
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
|
||||
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
|
||||
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
|
||||
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
|
||||
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
|
||||
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
|
||||
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
|
||||
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
|
||||
* after the first divergence belongs to a different button.
|
||||
*
|
||||
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
|
||||
* right; the assertions read it back the other way.
|
||||
*/
|
||||
class PadButtonsTest {
|
||||
|
||||
private fun sony(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
private fun xbox(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
/**
|
||||
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
|
||||
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
|
||||
* read as a face button.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's shoulders stop being each other's buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
|
||||
}
|
||||
|
||||
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
|
||||
@Test
|
||||
fun `a DualSense's face buttons land on their Xbox positions`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
|
||||
* which took the exit, mic and stats chords with it — every one of them is built on Select.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's menu buttons and stick clicks are themselves`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@Test
|
||||
fun `an Xbox pad numbering straight through keeps its own layout`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
|
||||
}
|
||||
|
||||
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
|
||||
@Test
|
||||
fun `a driver-backed Sony pad has only its face pair corrected`() {
|
||||
val m = Gamepad.PadButtons.SONY_MODERN
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
|
||||
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
|
||||
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
|
||||
* `Generic.kl` would have said came from a device-specific key layout, which knows this
|
||||
* controller better than any table here. Correcting it would break a pad that works.
|
||||
*/
|
||||
@Test
|
||||
fun `a keycode a device layout already resolved is never second-guessed`() {
|
||||
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
}
|
||||
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
assertEquals(once, p.correct(scan, once))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half. A pad that names its triggers something Android knows is read exactly as it
|
||||
* always was — this is the branch that must NOT fire on the pads that already work.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is read unchanged`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
|
||||
}
|
||||
// Same when there is no Rx/Ry to fall back to in the first place.
|
||||
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
|
||||
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
|
||||
* stick, then the triggers. Only the triggers were being missed; the sticks already read
|
||||
* right and must be left alone.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
|
||||
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
|
||||
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
|
||||
* pairs must never be mixed up, which is the whole point of pinning them.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger axis that idles at −1 is rescaled; one that idles at 0 must NOT be, or it would
|
||||
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
|
||||
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
|
||||
*/
|
||||
@Test
|
||||
fun `only a trigger that idles negative is rescaled`() {
|
||||
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(0f, signed.level(-1f), 1e-6f)
|
||||
assertEquals(0.5f, signed.level(0f), 1e-6f)
|
||||
assertEquals(1f, signed.level(1f), 1e-6f)
|
||||
|
||||
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(0f, unsigned.level(0f), 1e-6f)
|
||||
assertEquals(1f, unsigned.level(1f), 1e-6f)
|
||||
}
|
||||
|
||||
/** A pad Android does know is untouched, which is most of them. */
|
||||
@Test
|
||||
fun `a pad with a key layout is left alone`() {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val generic = Gamepad.genericKeyCode(scan)
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
|
||||
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
|
||||
impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_w: i32,
|
||||
surface_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_w, surface_h)?;
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
|
||||
@@ -96,8 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -199,8 +198,7 @@ pub(super) fn run_async(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
|
||||
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
|
||||
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
|
||||
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
|
||||
/// couldn't read it yet, and the backend falls back to the window buffer size.
|
||||
pub surface_w: i32,
|
||||
pub surface_h: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
|
||||
dest_w: i32,
|
||||
dest_h: i32,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
@@ -287,13 +293,16 @@ impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
|
||||
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
|
||||
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
|
||||
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
|
||||
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
|
||||
/// yet) falls back to that buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
@@ -303,20 +312,11 @@ impl Layer {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let dest_w = if dest_w > 0 {
|
||||
dest_w
|
||||
} else {
|
||||
window.width().max(1)
|
||||
};
|
||||
let dest_h = if dest_h > 0 {
|
||||
dest_h
|
||||
} else {
|
||||
window.height().max(1)
|
||||
};
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
|
||||
window.width(),
|
||||
window.height(),
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
@@ -324,12 +324,20 @@ impl Layer {
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
dest_w,
|
||||
dest_h,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
@@ -370,11 +378,12 @@ impl Layer {
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: self.dest_w,
|
||||
bottom: self.dest_h,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
|
||||
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_w: _,
|
||||
surface_h: _,
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
|
||||
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
let df = dropped_frames.clone();
|
||||
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
|
||||
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
|
||||
// sentinel — which is exactly what makes it usable for testing that path.
|
||||
let rxp = rx_wire_packets.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut last_dropped = 0u64;
|
||||
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
|
||||
// window while it is zero, once when the first packets land, then stop — so a host that
|
||||
// predates the message is not flooded with "unknown control message" on a good session.
|
||||
let mut delivery_confirmed = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let d = df.load(Relaxed);
|
||||
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
last_report = std::time::Instant::now();
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
// Independent of whether there is a fresh loss sample: "no fresh sample" is
|
||||
// exactly the shape a dead data plane has, so gating it on one would silence
|
||||
// it in the state it exists to report.
|
||||
let received = rxp.load(Relaxed);
|
||||
if received == 0 || !delivery_confirmed {
|
||||
delivery_confirmed = received > 0;
|
||||
if io::write_msg(
|
||||
&mut ls,
|
||||
&DeliveryReport {
|
||||
packets_received: received,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
|
||||
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
|
||||
/// there isn't (the library grid spends up on rows) the same menu hangs off
|
||||
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
|
||||
Up,
|
||||
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
|
||||
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
|
||||
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,9 @@ enum Resolved {
|
||||
/// The d-pad's up — drawn the same in every style, because it is a direction rather
|
||||
/// than a button whose label changes with the pad.
|
||||
Up,
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Shoulders => Resolved::Key("Tab"),
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
};
|
||||
}
|
||||
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Secondary => Resolved::Badge(Face::Y),
|
||||
HintKey::Shoulders => Resolved::Shoulders,
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
}
|
||||
@@ -394,17 +403,23 @@ fn draw_glyph(
|
||||
pen += w + 3.0 * k;
|
||||
}
|
||||
}
|
||||
Resolved::Up => {
|
||||
// ▲ — one solid triangle in a badge-sized slot.
|
||||
g @ (Resolved::Up | Resolved::Down) => {
|
||||
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
|
||||
// way up: apex toward the direction it names, base at the other end.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
|
||||
let (apex, base) = if matches!(g, Resolved::Down) {
|
||||
(cyf + th, cyf - th)
|
||||
} else {
|
||||
(cyf - th, cyf + th)
|
||||
};
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, apex));
|
||||
tri.line_to((cx - tw, base));
|
||||
tri.line_to((cx + tw, base));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
|
||||
pub pads: &'a [PadInfo],
|
||||
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
|
||||
pub deck: bool,
|
||||
/// The host app has another interface to fall back to when the console is switched
|
||||
/// off (an Android phone/tablet's touch shell) — see
|
||||
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
|
||||
pub fallback_ui: bool,
|
||||
/// The name the HOST stores this client under when pairing (the machine's
|
||||
/// hostname, resolved by the binary).
|
||||
pub device_name: &'a str,
|
||||
|
||||
@@ -396,6 +396,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -301,6 +302,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -323,6 +325,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -378,6 +378,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -429,6 +430,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -206,7 +206,17 @@ impl HomeScreen {
|
||||
}
|
||||
_ => Some(MenuPulse::Boundary),
|
||||
},
|
||||
MenuEvent::Move(_) => None,
|
||||
// Down is Settings — the same screen X opens. The carousel is horizontal, so
|
||||
// down is the other free direction, and it is the only route to Settings on a
|
||||
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
|
||||
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
|
||||
// answered it by moving rows out to the ordinary Settings app.)
|
||||
MenuEvent::Move(MenuDir::Down) => {
|
||||
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
|
||||
ctx.store,
|
||||
)));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +289,15 @@ impl HomeScreen {
|
||||
{
|
||||
hints.push(Hint::new(HintKey::Up, "Options"));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
|
||||
// Name the route this device actually has. With no pad attached the legend is
|
||||
// already speaking keyboard, and the one input that reaches here with neither a
|
||||
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
|
||||
// Down opens Settings for everyone; only the advertisement changes.
|
||||
hints.push(if ctx.pads.is_empty() {
|
||||
Hint::new(HintKey::Down, "Settings")
|
||||
} else {
|
||||
Hint::new(HintKey::Tertiary, "Settings")
|
||||
});
|
||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||
hints
|
||||
}
|
||||
@@ -859,6 +877,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -885,6 +904,67 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
|
||||
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
|
||||
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
|
||||
/// legend names the direction rather than X when nothing is plugged in.
|
||||
#[test]
|
||||
fn a_remote_reaches_settings_and_options_without_face_buttons() {
|
||||
let mut settings = ctx_settings();
|
||||
let hosts = [host("paired", true, true, false)];
|
||||
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Android,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: true,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
|
||||
// Down opens the same screen X opens.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
|
||||
"down must open Settings"
|
||||
);
|
||||
// Up still opens the host's own menu — the library hangs off that menu now.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
|
||||
"up must open the host options menu"
|
||||
);
|
||||
// With no pad the legend advertises the direction, not a button that isn't there.
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
|
||||
"a padless device is told about down"
|
||||
);
|
||||
// With a pad it goes back to naming X, which is faster to press.
|
||||
let pads = vec![pf_client_core::menu_nav::PadInfo {
|
||||
name: "Pad".into(),
|
||||
key: "045e:028e:Pad".into(),
|
||||
pref: punktfunk_core::config::GamepadPref::Xbox360,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "045E:028E · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
}];
|
||||
ctx.pads = &pads;
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
|
||||
"a pad is told about X"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
@@ -908,6 +988,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -932,6 +1013,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -2218,6 +2218,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
|
||||
enum Action {
|
||||
Wake,
|
||||
SendLogs,
|
||||
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
|
||||
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
|
||||
/// exactly like that Y (an unpaired host has no shelf to fetch).
|
||||
Library,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
@@ -154,6 +158,12 @@ impl OptionsScreen {
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
|
||||
// because it is the one row here that goes somewhere rather than acting on the
|
||||
// host — and on a remote-only device it is the ONLY way to the library.
|
||||
if host.paired && host.saved {
|
||||
a.push(Action::Library);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
@@ -171,6 +181,7 @@ impl OptionsScreen {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::Library => "Library".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
@@ -234,7 +245,7 @@ impl OptionsScreen {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
self.run(action, ctx.store, fx);
|
||||
self.run(action, ctx, fx);
|
||||
pulse
|
||||
}
|
||||
}
|
||||
@@ -257,7 +268,8 @@ impl OptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
|
||||
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
|
||||
let store = ctx.store;
|
||||
let key = self.host_key().to_string();
|
||||
match action {
|
||||
Action::Wake => {
|
||||
@@ -289,6 +301,24 @@ impl OptionsScreen {
|
||||
}
|
||||
fx.pop();
|
||||
}
|
||||
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
|
||||
// on the epoch read BEFORE the command drains, so the screen can tell its own
|
||||
// fetch's titles from the ones already in the model. `replace`, not push — the
|
||||
// menu has said its piece, and Back from the shelf belongs on the carousel
|
||||
// rather than on a menu about the host you just left.
|
||||
Action::Library => {
|
||||
let host = self.host();
|
||||
fx.cmds.push(ConsoleCmd::FetchLibrary {
|
||||
addr: host.addr.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
});
|
||||
let epoch = ctx.library.fetch_epoch();
|
||||
fx.replace(Screen::Library(super::library::LibraryScreen::new(
|
||||
self.host(),
|
||||
epoch,
|
||||
)));
|
||||
}
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
@@ -407,6 +437,27 @@ mod tests {
|
||||
use crate::model::ProfileChip;
|
||||
use crate::screens::Nav;
|
||||
|
||||
/// Activate one row. `run` reads the store, and — for Library — the shared library's
|
||||
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
|
||||
/// enough for every action test here.
|
||||
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
|
||||
let mut settings = pf_client_core::trust::Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
s.run(action, &ctx, fx);
|
||||
}
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
@@ -510,6 +561,38 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// The shelf is on this menu, which is the only route to it that survives a device with
|
||||
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
|
||||
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
|
||||
/// the shelf lands on the carousel rather than on a menu about the host just left.
|
||||
#[test]
|
||||
fn the_library_hangs_off_the_menu_for_a_padless_device() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
run_action(&mut s, Action::Library, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
|
||||
"opening the shelf asks for it first"
|
||||
);
|
||||
match fx.nav {
|
||||
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
|
||||
_ => panic!("expected the shelf to replace the menu"),
|
||||
}
|
||||
|
||||
// An unpaired host has no shelf to fetch — the row is absent, not inert.
|
||||
let unpaired = OptionsScreen::for_host(&HostRow {
|
||||
paired: false,
|
||||
..host()
|
||||
});
|
||||
assert!(!unpaired
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
@@ -520,7 +603,7 @@ mod tests {
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::BindProfile, &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
@@ -537,7 +620,7 @@ mod tests {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -551,7 +634,7 @@ mod tests {
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -569,12 +652,12 @@ mod tests {
|
||||
s.list.cursor = i;
|
||||
let mut fx = Outbox::default();
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert!(fx.cmds.is_empty(), "the first press only arms");
|
||||
assert!(s.armed);
|
||||
assert!(s.label(Action::Forget).contains("press again"));
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
|
||||
@@ -597,6 +680,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -659,7 +743,7 @@ mod tests {
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
] {
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::CopyLink, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
assert!(fx.toast.is_some());
|
||||
}
|
||||
|
||||
@@ -497,6 +497,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "living-room-deck",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -537,6 +538,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "deck",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -274,6 +275,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,12 @@ enum RowId {
|
||||
Sc2Passthrough,
|
||||
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
|
||||
DsCapture,
|
||||
/// Whether the console UI fronts the app at all — the touch settings' switch
|
||||
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
|
||||
/// Only offered where there is another interface to fall back to
|
||||
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
|
||||
/// UI, and an off switch would strand the user in nothing.
|
||||
GamepadUi,
|
||||
/// When the console UI fronts the app: with a controller attached, or always.
|
||||
GamepadUiMode,
|
||||
/// The platform's connected-controllers view (an action row — opens a native screen).
|
||||
@@ -121,6 +127,7 @@ mod android_keys {
|
||||
pub const SC2: &str = "android.sc2_capture";
|
||||
pub const DS_CAPTURE: &str = "android.ds_capture";
|
||||
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
|
||||
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -245,6 +252,7 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
],
|
||||
@@ -384,7 +392,7 @@ impl SettingsScreen {
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
@@ -675,6 +683,7 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::PhoneGyro
|
||||
| RowId::Sc2Passthrough
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
@@ -694,9 +703,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
|
||||
// The console-off switch needs somewhere for "off" to land: only clients with a
|
||||
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
|
||||
// this console is the only UI, and off would strand the user (the touch settings'
|
||||
// subtitle even promises "A TV always uses it").
|
||||
RowId::GamepadUi => ctx.fallback_ui,
|
||||
// The same two conditions the mode decides anything under: a TV is in console mode
|
||||
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
|
||||
// while the switch above is off nothing fronts the console at all. Hidden rather
|
||||
// than dimmed, like the touch screen's picker, and it sits directly below the row
|
||||
// that drops it so the cursor is never under anything that moves.
|
||||
RowId::GamepadUiMode => {
|
||||
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -962,9 +984,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"DualSense over USB",
|
||||
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
|
||||
),
|
||||
RowId::GamepadUi => (
|
||||
None,
|
||||
"Controller-optimized UI",
|
||||
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
|
||||
),
|
||||
RowId::GamepadUiMode => (
|
||||
None,
|
||||
"Controller UI",
|
||||
// The touch screen's word for the same picker, which now sits under the same
|
||||
// switch it does there — "Controller UI" beside "Controller-optimized UI"
|
||||
// would be two rows a reader has to tell apart by their tails.
|
||||
"Show it",
|
||||
label_for(
|
||||
&GAMEPAD_UI_MODES,
|
||||
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
@@ -1145,9 +1175,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
|
||||
Needs the USB grant when the pad is plugged in."
|
||||
}
|
||||
RowId::GamepadUi => {
|
||||
"Front the app with this console instead of the touch interface. Off returns \
|
||||
to the touch home immediately — switch it back on there."
|
||||
}
|
||||
RowId::GamepadUiMode => {
|
||||
"When this console fronts the app: whenever a controller is attached, or \
|
||||
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
|
||||
always — for a device that lives docked to a TV. The switch above turns it \
|
||||
off altogether."
|
||||
}
|
||||
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
|
||||
RowId::Licenses => "The open-source licences this app ships under.",
|
||||
@@ -1375,6 +1410,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
|
||||
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
|
||||
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
|
||||
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
|
||||
RowId::GamepadUiMode => {
|
||||
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
|
||||
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
|
||||
@@ -1504,6 +1540,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1538,6 +1575,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1603,6 +1641,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1652,6 +1691,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1686,6 +1726,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1727,6 +1768,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1757,6 +1799,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1793,6 +1836,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1867,6 +1911,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1900,6 +1945,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1931,6 +1977,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1960,6 +2007,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2009,6 +2057,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2060,6 +2109,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2105,6 +2155,7 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
]
|
||||
@@ -2147,6 +2198,9 @@ pub(super) mod tests {
|
||||
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
"always"
|
||||
);
|
||||
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
|
||||
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
// Only `extra` moved.
|
||||
let mut after = ctx.settings.clone();
|
||||
after.extra = before.extra.clone();
|
||||
@@ -2154,6 +2208,34 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The console-off switch exists only where there is a fallback interface for "off"
|
||||
/// to land in, and the mode row under it only where the mode decides anything: not on
|
||||
/// a TV (always console, whatever the mode says) and not while the switch is off.
|
||||
#[test]
|
||||
fn console_off_switch_needs_a_fallback_ui() {
|
||||
with_ctx(|ctx| {
|
||||
ctx.platform = crate::platform::Platform::Android;
|
||||
// A TV: no off switch (it would strand the user), and no mode row either —
|
||||
// `gamepadUiActive`'s tv term satisfies the OR on its own.
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUi, ctx),
|
||||
"a TV offers no off switch"
|
||||
);
|
||||
assert!(!row_applies(RowId::GamepadUiMode, ctx));
|
||||
// A phone or tablet with the console on: both rows.
|
||||
ctx.fallback_ui = true;
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(row_applies(RowId::GamepadUiMode, ctx));
|
||||
// Switched off: the switch stays (it is the way back), the mode row goes.
|
||||
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUiMode, ctx),
|
||||
"the mode row decides nothing while the switch above it is off"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
@@ -2168,9 +2250,9 @@ pub(super) mod tests {
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
// 35 desktop rows + the nine Android-only ones (design android-skia-console-port.md
|
||||
// D3): seven `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 44, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
@@ -2209,6 +2291,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2250,6 +2333,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2295,6 +2379,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2373,6 +2458,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
|
||||
pub device_name: String,
|
||||
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
||||
pub deck: bool,
|
||||
/// Whether the host app has another interface to fall back to when the console is
|
||||
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
|
||||
/// on the settings screen; false where this console is the only UI there is (the
|
||||
/// desktop session, an Android TV), where offering "off" would strand the user.
|
||||
pub fallback_ui: bool,
|
||||
/// Where settings persist and the profile catalog comes from. `None` = the desktop
|
||||
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
|
||||
/// only store there is on Linux/Windows; every other host must supply one.
|
||||
@@ -203,6 +208,7 @@ impl ConsoleOptions {
|
||||
ConsoleOptions {
|
||||
device_name,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
store: None,
|
||||
platform: Platform::Desktop,
|
||||
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
|
||||
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
|
||||
hosts_gen: u64,
|
||||
device_name: String,
|
||||
deck: bool,
|
||||
/// See [`ConsoleOptions::fallback_ui`].
|
||||
fallback_ui: bool,
|
||||
pub(crate) in_stream: bool,
|
||||
connecting: Option<Connecting>,
|
||||
/// The last host title a connect was raised for, kept past the connect itself so
|
||||
@@ -352,6 +360,7 @@ impl Shell {
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
deck: opts.deck,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
in_stream: false,
|
||||
connecting: None,
|
||||
last_connect_title: None,
|
||||
@@ -888,6 +897,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
@@ -951,6 +961,10 @@ impl Shell {
|
||||
// navigation but "open this tile's menu". Without this the context menu —
|
||||
// and with it the only way to copy a host's link — is pad-only.
|
||||
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
|
||||
// ▼ is the same kind of hint: a direction that steers nothing, because
|
||||
// the only screen publishing it is the home carousel, where down means
|
||||
// "open Settings". A finger must be able to press what it advertises.
|
||||
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
@@ -970,6 +984,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
|
||||
@@ -172,6 +172,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t,
|
||||
glyphs: self.glyphs,
|
||||
@@ -331,6 +332,8 @@ struct LayerEnv<'a> {
|
||||
platform: crate::platform::Platform,
|
||||
pads: &'a [PadInfo],
|
||||
deck: bool,
|
||||
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
|
||||
fallback_ui: bool,
|
||||
device_name: &'a str,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
@@ -365,6 +368,7 @@ impl LayerEnv<'_> {
|
||||
platform: self.platform,
|
||||
pads: self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: self.device_name,
|
||||
t: self.t,
|
||||
};
|
||||
|
||||
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
|
||||
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
|
||||
finish_motion(&mut s);
|
||||
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
|
||||
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
|
||||
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
|
||||
// quietly landing this test's Confirm on "Forget".
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
|
||||
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, …] — Edit is three
|
||||
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
|
||||
// instead of quietly landing this test's Confirm on something destructive.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
|
||||
@@ -396,9 +396,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
|
||||
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
|
||||
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
|
||||
let r = ((corner - 0.5) * k).max(0.0);
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
|
||||
}
|
||||
|
||||
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
|
||||
/// rect AND the corner radius take it — see the draw there.
|
||||
const HALO_OUTSET: f32 = 4.0;
|
||||
|
||||
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
|
||||
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
|
||||
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
|
||||
@@ -439,8 +446,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
|
||||
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
|
||||
// farthest-first the focused card's corona landed on top of its neighbours — which is
|
||||
// what made every card look like it was glowing.
|
||||
let spread = rect.with_outset((4.0 * k, 4.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
|
||||
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
|
||||
// original's only if its radius grows by `d` too (the two arcs then share a centre).
|
||||
// Reusing the card's own radius left the halo squarer than the card it sits under, so
|
||||
// it read as a misaligned outline at the four corners and a clean glow along the edges.
|
||||
let r = (corner + HALO_OUTSET) * k;
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
|
||||
@@ -116,11 +116,16 @@ static MANAGED_LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The display-manager unit we stopped for the takeover (any DM that drove a LIVE gaming session
|
||||
/// is stopped for the stream — see [`dm_plan`]), so the restore brings the box back via
|
||||
/// A display-manager unit stopped for a takeover, so the restore brings the box back via
|
||||
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||
/// cannot work on a mask-fragile flavor: without a DM login session there is no seat, so gamescope
|
||||
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
///
|
||||
/// ⚠ **Adoption-only since 0.31.0**: the takeover idles the box's autologin session
|
||||
/// ([`install_idle_dropin`]) and leaves the DM up, so nothing in this process ever writes this any
|
||||
/// more — only [`restore_takeover_on_startup`], for a takeover stranded by a host old enough to
|
||||
/// have stopped one. It is therefore NOT the marker of a live takeover; [`takeover_idled`] is.
|
||||
/// Reading it as that marker is what silently unreachable-d the in-stream switch gate in 0.31.0.
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
|
||||
@@ -136,12 +141,16 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||
/// therefore the one durable trace of the user's switch request.
|
||||
/// "Switch to Desktop" detector compares against. The ChimeraOS-layout `os-session-select`
|
||||
/// (Nobara, ChimeraOS) writes `~/.config/steamos-session-select` unconditionally in its USER pass,
|
||||
/// before any of its display-manager checks, so an advanced mtime after a capture loss is the one
|
||||
/// durable trace of the user's switch request — the switch itself leaves nothing else behind that
|
||||
/// this host can see.
|
||||
///
|
||||
/// ⚠ Bazzite/SteamOS write NO sentinel: their `os-session-select` is a thin wrapper over
|
||||
/// `steamosctl` D-Bus calls. The detector is therefore inert there by construction, which is
|
||||
/// exactly right — those platforms default the mid-stream session watcher ON
|
||||
/// ([`is_steam_htpc_platform`]) and follow the switch with it instead.
|
||||
///
|
||||
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
|
||||
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
|
||||
@@ -674,21 +683,28 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode, hdr);
|
||||
}
|
||||
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(dm) = dm_takeover {
|
||||
if session_select_requested() {
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
honor_session_select_switch(dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — display manager restored; \
|
||||
re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// In-stream "Switch to Desktop": the user's session-select inside the streamed game mode
|
||||
// advanced the sentinel, so the box is on its way to a desktop session. Without this, the
|
||||
// capture loss that switch causes just relaunches game mode over the booting desktop — the
|
||||
// "thrown back in" field report of 2026-07-24, and again on Nobara 2026-08-20.
|
||||
//
|
||||
// ⚠ Gated on the IDLED takeover, not on [`STOPPED_DM`]. Until 0.31.0 the takeover stopped the
|
||||
// display manager, and setting that static was what armed this gate; the idled takeover
|
||||
// replaced both the stop and the static ([`install_idle_dropin`]) and nothing re-armed the
|
||||
// gate, so this branch became unreachable on every box. Bazzite did not notice — its
|
||||
// `os-session-select` is a `steamosctl` D-Bus call that writes no sentinel, and its session
|
||||
// watcher is on by default ([`is_steam_htpc_platform`]) so the stream follows the switch
|
||||
// anyway. The ChimeraOS-layout distros are the ones that lost their handling: their
|
||||
// `os-session-select` DOES write the sentinel, and `ID=nobara` matches no HTPC default.
|
||||
if takeover_idled() && session_select_requested() {
|
||||
// `take`, so an adopted DM stop is consumed exactly once — see
|
||||
// [`honor_session_select_switch`] for why a 0.31.0 takeover has none to consume.
|
||||
let adopted_dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
honor_session_select_switch(adopted_dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — the box's own game mode is handed \
|
||||
back; re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||
@@ -1349,19 +1365,34 @@ fn install_idle_dropin() -> Result<()> {
|
||||
.parent()
|
||||
.context("the idle drop-in path has no parent directory")?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Service]\nExecStart=\nExecStart={} infinity\n",
|
||||
sleep_binary()
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
std::fs::write(&path, idle_dropin_body(sleep_binary()))
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The idle drop-in's body (the unit-testable core of [`install_idle_dropin`]).
|
||||
///
|
||||
/// The **empty `ExecStart=` comes first and is load-bearing**: `ExecStart` is a list-valued
|
||||
/// directive, so a drop-in that only adds a line APPENDS to the box's own — which would run the
|
||||
/// real gamescope session *and* the sleep, i.e. exactly the Steam-fighting session the takeover
|
||||
/// exists to get out of the way, with no symptom pointing here. The reset is what replaces it.
|
||||
fn idle_dropin_body(sleep_bin: &str) -> String {
|
||||
format!("[Service]\nExecStart=\nExecStart={sleep_bin} infinity\n")
|
||||
}
|
||||
|
||||
/// Does THIS host hold the box's game mode idled right now? The successor to "did we stop the
|
||||
/// display manager" as the marker of a live managed takeover, and so what arms the in-stream
|
||||
/// switch gate in [`create_managed_session`].
|
||||
///
|
||||
/// Reads [`IDLE_DROPIN_ARMED`] — this process's own memory — deliberately, unlike
|
||||
/// [`remove_idle_dropin`]: a drop-in on disk that we did not write belongs to a dead host, and
|
||||
/// honoring a "switch" against someone else's takeover would hand back a box we never took.
|
||||
fn takeover_idled() -> bool {
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
|
||||
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
|
||||
/// the drop-in outliving a host that died is exactly the case that has to be swept.
|
||||
@@ -2282,10 +2313,36 @@ fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
|
||||
}
|
||||
|
||||
/// The host's mid-stream session watcher calls this on every switch it confirms; see
|
||||
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
|
||||
/// [`switch_ends_mask_window`] for which ones end the takeover's hold on the box's own game mode.
|
||||
///
|
||||
/// This is the SECOND of the two ways a box can leave our takeover mid-stream — the sentinel
|
||||
/// detector in [`create_managed_session`] is the other — and both owe the box the same hand-back.
|
||||
/// The watcher is the one that covers Bazzite/SteamOS, where it is on by default
|
||||
/// ([`is_steam_htpc_platform`]) and no sentinel is ever written; the detector covers the
|
||||
/// ChimeraOS-layout distros, which are the reverse. Fixing only one leaves the other's boxes
|
||||
/// holding an idled game mode.
|
||||
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
|
||||
if switch_ends_mask_window(switched_to) {
|
||||
lift_autologin_mask();
|
||||
if !switch_ends_mask_window(switched_to) {
|
||||
return;
|
||||
}
|
||||
lift_autologin_mask();
|
||||
// The idle drop-in is the mask's successor and inherits its whole hazard: it replaces the
|
||||
// box's game-mode `ExecStart` with a sleep, and a switch to a desktop is exactly where that
|
||||
// stops being ours to hold. Left on, the user's "Return to Gaming Mode" starts a unit that
|
||||
// only sleeps — the same barred way back this function's mask lift exists to prevent, and
|
||||
// measured in that state on Bazzite `.41` 2026-08-20 (`ExecStart=/usr/bin/sleep infinity`
|
||||
// still on the unit after a completed switch to KDE).
|
||||
//
|
||||
// Deliberately NOT a full [`clear_takeover`]: the takeover outlives this window, exactly as
|
||||
// the mask lift's own note says. The box may come back to game mode, and the disconnect
|
||||
// restore still owes [`STOPPED_AUTOLOGIN`] a start. All this says is "the box's own game mode
|
||||
// runs for real again".
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
switched_to = ?switched_to,
|
||||
"gamescope: the box left our game session for a desktop — removed the takeover's idle \
|
||||
drop-in so its own Game Mode runs for real again"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2754,11 +2811,12 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
|
||||
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
|
||||
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
|
||||
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
|
||||
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
|
||||
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
|
||||
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
|
||||
/// as a live request and pushing the box to the desktop the user never asked for.
|
||||
/// the idle drop-in goes in, which is what arms the honor gate — see [`takeover_idled`]) and again
|
||||
/// at a successful launch: the switch INTO game mode writes the sentinel on its way in, and that
|
||||
/// write must never read as a request to go back out. Baselining only at launch left the window in
|
||||
/// between — a takeover whose launch failed, then a client retry inside the restore debounce —
|
||||
/// reading a months-old sentinel as a live request and pushing the box to the desktop the user
|
||||
/// never asked for.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
@@ -2801,11 +2859,11 @@ fn sentinel_advanced(
|
||||
///
|
||||
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||
fn honor_session_select_switch(dm: String) {
|
||||
fn honor_session_select_switch(adopted_dm: Option<String>) {
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||
switching the box to the desktop session"
|
||||
adopted_dm = ?adopted_dm,
|
||||
"gamescope: in-stream session-select detected — handing the box's own game mode back and \
|
||||
following the desktop session the user selected"
|
||||
);
|
||||
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
|
||||
// FIRST and while the unit list still exists — this path discards that list, and it is the only
|
||||
@@ -2818,7 +2876,43 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
if let Err(e) = restore_display_manager(&dm) {
|
||||
// Give the box its own Game Mode back before anything else can ask for it. The takeover
|
||||
// replaced that session's `ExecStart` with a sleep ([`install_idle_dropin`]), and a switch is
|
||||
// the one exit that used to leave it behind: the disconnect restore sweeps it, but a switch is
|
||||
// not a disconnect. Without this the user's next "Return to Gaming Mode" starts a unit that
|
||||
// does nothing at all — measured on the Nobara VM 2026-08-20, and on glass it is
|
||||
// indistinguishable from broken hardware.
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
|
||||
real again"
|
||||
);
|
||||
}
|
||||
// Only an ADOPTED takeover still owes a display-manager restore. 0.31.0 leaves the DM up for
|
||||
// exactly this reason, so by the time we get here the OS's own switch has already done the
|
||||
// whole job — config rewrite and relogin (measured end to end on the Nobara VM). A takeover
|
||||
// inherited from a host old enough to have STOPPED the DM has not: for it the switch really
|
||||
// was the silent no-op that every write branch of `os-session-select` becomes without a
|
||||
// running DM, so that one still has to be replayed.
|
||||
if let Some(dm) = adopted_dm {
|
||||
replay_switch_under_restored_dm(&dm);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Restore a display manager an ADOPTED takeover stopped, then replay the user's switch under it —
|
||||
/// every verb live-validated on the Nobara repro VM:
|
||||
/// 1. start the DM (its autologin heads back into game mode briefly — the config still names it);
|
||||
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||
/// into the now-selected desktop.
|
||||
///
|
||||
/// Reachable only from [`honor_session_select_switch`], and only for a takeover inherited from a
|
||||
/// pre-0.31.0 host: nothing stops a display manager any more.
|
||||
fn replay_switch_under_restored_dm(dm: &str) {
|
||||
if let Err(e) = restore_display_manager(dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %e,
|
||||
@@ -2831,7 +2925,7 @@ fn honor_session_select_switch(dm: String) {
|
||||
// Budgeted: this is a 10 s loop, and a single unbounded `is-active` against a system
|
||||
// manager that is itself mid-restart would consume the whole window in one tick.
|
||||
let active = crate::proc::output_within(
|
||||
Command::new("systemctl").args(["is-active", &dm]),
|
||||
Command::new("systemctl").args(["is-active", dm]),
|
||||
UNIT_STATE_BUDGET,
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
@@ -2888,8 +2982,6 @@ fn honor_session_select_switch(dm: String) {
|
||||
session instead of switching to the desktop"
|
||||
);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
@@ -2979,6 +3071,13 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
// switch.
|
||||
if plan.dm_relogins {
|
||||
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
|
||||
// Baseline the switch sentinel HERE, not only at a successful launch: arming the idle
|
||||
// drop-in is what arms the honor gate in [`create_managed_session`], so from this instant
|
||||
// an unbaselined sentinel would read as an in-stream "Switch to Desktop" — including the
|
||||
// write left by the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still). This moved here from the display-manager stop that 0.31.0
|
||||
// retired; losing it with that stop is what left the gate unarmed.
|
||||
record_session_select_baseline();
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
@@ -5233,9 +5332,10 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
|
||||
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
@@ -5434,6 +5534,25 @@ mod tests {
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), None));
|
||||
}
|
||||
|
||||
/// `ExecStart` is list-valued, so the reset line is the whole mechanism: without it the
|
||||
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
|
||||
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
|
||||
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
|
||||
#[test]
|
||||
fn idle_dropin_replaces_exec_start_rather_than_appending() {
|
||||
let body = idle_dropin_body("/usr/bin/sleep");
|
||||
assert_eq!(
|
||||
body, "[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n",
|
||||
"{body}"
|
||||
);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines[1], "ExecStart=", "the reset must come first: {body}");
|
||||
// The path is resolved per box ([`sleep_binary`]) and must reach the unit verbatim — a
|
||||
// bare `sleep` would depend on the unit's PATH, and an ExecStart that fails to EXECUTE is
|
||||
// the failing unit the display manager relogin-loops against.
|
||||
assert!(idle_dropin_body("/bin/sleep").contains("ExecStart=/bin/sleep infinity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wrapper_script_shapes() {
|
||||
let relay = std::path::Path::new("/run/user/1000/pf-ei");
|
||||
@@ -5661,10 +5780,32 @@ mod tests {
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
|
||||
// The idle drop-in is the mask's successor and shares this exact window, so it has to come
|
||||
// off with it. A takeover that leaves it on has replaced the box's game-mode `ExecStart`
|
||||
// with a sleep — a "Return to Gaming Mode" that starts and does nothing, which is the same
|
||||
// barred way back, measured on Bazzite `.41` 2026-08-20.
|
||||
install_idle_dropin().expect("arm the takeover's idle drop-in");
|
||||
assert!(idle_dropin_path().exists());
|
||||
|
||||
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
|
||||
// what our own managed session reads as, and `None` is one momentarily down between
|
||||
// relaunches — lifting on either would void the mask for the whole stream.
|
||||
release_autologin_mask(crate::ActiveKind::Gaming);
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
idle_dropin_path().exists(),
|
||||
"the idle drop-in must survive a switch that is not to a desktop"
|
||||
);
|
||||
|
||||
// The user switched the box to its own desktop mid-stream: the window is over, and the way
|
||||
// back into game mode has to be clear before they ask for it.
|
||||
release_autologin_mask(crate::ActiveKind::DesktopKde);
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
assert!(
|
||||
!idle_dropin_path().exists(),
|
||||
"the idle drop-in outlived the switch — the box's Game Mode is a sleep now"
|
||||
);
|
||||
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
|
||||
// and the disconnect restore still owes these units a `start`.
|
||||
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
|
||||
@@ -5673,6 +5814,7 @@ mod tests {
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
|
||||
unmask_unit(PROBE);
|
||||
remove_idle_dropin();
|
||||
STOPPED_AUTOLOGIN.lock().unwrap().clear();
|
||||
*AUTOLOGIN_MASKED.lock().unwrap() = false;
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
|
||||
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
use crate::quic::{
|
||||
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
|
||||
};
|
||||
|
||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
|
||||
/// forcing a full IDR. See [`RfiRequest`].
|
||||
Rfi(RfiRequest),
|
||||
Loss(LossReport),
|
||||
/// How many data-plane packets have reached us all session — sent straight after every
|
||||
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
|
||||
/// identical) and only this separates them. See [`DeliveryReport`].
|
||||
Delivery(DeliveryReport),
|
||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||
SetBitrate(u32),
|
||||
|
||||
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
|
||||
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
|
||||
/// (nothing arriving, versus more arriving than it can drain).
|
||||
///
|
||||
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
|
||||
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
|
||||
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
|
||||
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
|
||||
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
|
||||
/// local copy is exactly the drift that made the two indistinguishable in the first place.
|
||||
///
|
||||
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
|
||||
/// send one; this keeps the period itself informative for those that are not.
|
||||
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
|
||||
|
||||
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
||||
|
||||
@@ -42,7 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
|
||||
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use crate::transport::UdpTransport;
|
||||
|
||||
@@ -107,6 +107,7 @@ impl ControlTask {
|
||||
}
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::Delivery(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
if clock_rtt_ns.is_none() {
|
||||
|
||||
@@ -77,6 +77,12 @@ impl DataPump {
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
|
||||
// the delivery count is reported every window while it is ZERO (the state the host acts on)
|
||||
// and once more when the first packets land, then never again. A host that predates the
|
||||
// message logs "unknown control message" for each one, so a healthy session must not stream
|
||||
// them — one line per session is a fair price on an old host, eighty a minute is not.
|
||||
let mut delivery_confirmed = false;
|
||||
let (
|
||||
mut last_recovered,
|
||||
mut last_late,
|
||||
@@ -415,6 +421,27 @@ impl DataPump {
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
|
||||
// host, which cannot otherwise tell a flawless link from one delivering
|
||||
// nothing. The session TOTAL, not this window's, so one message stands on its
|
||||
// own. Deliberately inside the same arm: a discarded window is discarded
|
||||
// because the host was rebuilding or a probe distorted it, and staying silent
|
||||
// there keeps that contract exact. Nothing is lost — the state this reports
|
||||
// (no packets at all) produces no discards, so its windows always send.
|
||||
//
|
||||
// Sent every window while the count is ZERO, then ONCE when the first packets
|
||||
// land (so the host stops guessing and can name the other failure confidently),
|
||||
// then never again: a healthy session must not stream a message that older
|
||||
// hosts log as unknown on every arrival.
|
||||
// ponytail: only start-of-session death is covered. A path that dies MID-stream
|
||||
// leaves the count frozen above zero and silent, which the host still reads as
|
||||
// healthy — detecting that needs a stalled-counter check with its own timing,
|
||||
// worth adding if a mid-session case is ever reported.
|
||||
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
|
||||
packets_received: st.packets_received,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
@@ -757,10 +784,58 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
|
||||
///
|
||||
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
|
||||
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
|
||||
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
|
||||
/// the message logs every unknown control message, and a healthy hours-long session must not fill
|
||||
/// its log with them.
|
||||
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
|
||||
let owed = packets_received == 0 || !*confirmed;
|
||||
*confirmed = packets_received > 0;
|
||||
owed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
|
||||
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
|
||||
/// on an older host, which warns per unknown message, is what pays for the first two.
|
||||
#[test]
|
||||
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
|
||||
let mut confirmed = false;
|
||||
// Nothing arriving: reported every window, for as long as it stays true.
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
should_report_delivery(0, &mut confirmed),
|
||||
"a dead data plane must be re-reported every window"
|
||||
);
|
||||
}
|
||||
// First packets land: one confirmation, so the host can name the other failure confidently.
|
||||
assert!(should_report_delivery(500, &mut confirmed));
|
||||
// Healthy from here: silent.
|
||||
for n in [900, 1_200, 90_000] {
|
||||
assert!(
|
||||
!should_report_delivery(n, &mut confirmed),
|
||||
"a healthy session must not stream delivery reports"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A session that never receives anything must never look confirmed, no matter how long it runs
|
||||
/// — the whole point is that the host keeps being told.
|
||||
#[test]
|
||||
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
|
||||
let mut confirmed = false;
|
||||
for _ in 0..100 {
|
||||
assert!(should_report_delivery(0, &mut confirmed));
|
||||
assert!(!confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
@@ -935,8 +1010,8 @@ mod tests {
|
||||
.expect("the window after the gap reports on schedule");
|
||||
assert!(
|
||||
matches!(reported, Some(CtrlRequest::Loss(_))),
|
||||
"the window after the gap must produce a loss report — an idle session's only \
|
||||
outbound request"
|
||||
"the window after the gap must produce a loss report — the first of the two requests \
|
||||
an idle session makes (the delivery count follows it)"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(1_400),
|
||||
|
||||
@@ -97,6 +97,33 @@ pub struct LossReport {
|
||||
pub loss_ppm: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
|
||||
/// received all session, cumulative.
|
||||
///
|
||||
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
|
||||
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
|
||||
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
|
||||
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
|
||||
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
|
||||
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
|
||||
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
|
||||
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
|
||||
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
|
||||
///
|
||||
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
|
||||
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
|
||||
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
|
||||
/// normal here (the field case that motivated this ran a current host against a months-old client),
|
||||
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
|
||||
/// ignores every other control message it predates.
|
||||
///
|
||||
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
|
||||
/// mirrors, with no saturation to reason about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DeliveryReport {
|
||||
pub packets_received: u64,
|
||||
}
|
||||
|
||||
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
|
||||
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
|
||||
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
|
||||
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
|
||||
/// duration precisely so that no clock domain is involved.
|
||||
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
|
||||
/// Type byte of [`DeliveryReport`].
|
||||
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -436,6 +465,26 @@ impl LossReport {
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] packets_received[5..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_DELIVERY_REPORT);
|
||||
b.extend_from_slice(&self.packets_received.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
|
||||
}
|
||||
Ok(DeliveryReport {
|
||||
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SetBitrate {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] bitrate_kbps[5..9]
|
||||
@@ -1291,6 +1340,41 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_report_roundtrip() {
|
||||
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
|
||||
let r = DeliveryReport { packets_received };
|
||||
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||
}
|
||||
|
||||
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
|
||||
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
|
||||
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
|
||||
/// mixed-version case here (the field report that motivated this ran a current host against a
|
||||
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
|
||||
/// simply ignores the message it does not know.
|
||||
#[test]
|
||||
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
|
||||
let loss = LossReport { loss_ppm: 42 }.encode();
|
||||
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
|
||||
assert_eq!(loss[4], MSG_LOSS_REPORT);
|
||||
|
||||
let delivery = DeliveryReport {
|
||||
packets_received: 0,
|
||||
}
|
||||
.encode();
|
||||
assert_ne!(
|
||||
delivery[4], MSG_LOSS_REPORT,
|
||||
"a distinct type byte is what makes an old host ignore it instead of failing"
|
||||
);
|
||||
// Neither can be silently mis-parsed as the other.
|
||||
assert!(LossReport::decode(&delivery).is_err());
|
||||
assert!(DeliveryReport::decode(&loss).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
|
||||
@@ -114,9 +114,18 @@ pub enum LeaseKind {
|
||||
Child,
|
||||
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
|
||||
Matched,
|
||||
/// Nothing identifies this title's process — no detect signals and no child we own. Both
|
||||
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
|
||||
/// guessing.
|
||||
/// A launcher owns the game and **tells us** when it starts and stops
|
||||
/// ([`crate::runstate`]) — no process signal of our own.
|
||||
///
|
||||
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
|
||||
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
|
||||
/// through its own tracking and reports the edges, where the host could see only a
|
||||
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
|
||||
/// — the honest answer at the time, and a dead end.
|
||||
Reported,
|
||||
/// Nothing identifies this title's process — no detect signals, no child we own, and no
|
||||
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
|
||||
/// once in the log rather than guessing.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
@@ -126,6 +135,7 @@ impl LeaseKind {
|
||||
Self::Nested => "nested",
|
||||
Self::Child => "child",
|
||||
Self::Matched => "matched",
|
||||
Self::Reported => "reported",
|
||||
Self::Untracked => "untracked",
|
||||
}
|
||||
}
|
||||
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
LeaseKind::Child
|
||||
} else if !spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(game.id.as_deref()) {
|
||||
// Nothing to scan for, but the provider that published this title is reporting liveness for
|
||||
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
|
||||
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
|
||||
// would make both lifetime behaviors depend on a plugin's uptime.
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
};
|
||||
@@ -551,6 +567,27 @@ fn watch(
|
||||
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
|
||||
};
|
||||
|
||||
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
|
||||
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
|
||||
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
|
||||
// changes while the lease is alive.
|
||||
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
|
||||
|
||||
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
|
||||
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
|
||||
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
|
||||
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
|
||||
// hand-off, so every such lease arrives here.
|
||||
let fallback_kind = || {
|
||||
if !shared.spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Phase 1: wait for the game to show up. ----
|
||||
let start_deadline = spawned_at + START_GRACE;
|
||||
loop {
|
||||
@@ -567,8 +604,10 @@ fn watch(
|
||||
&& !spawned_up(&spawned)
|
||||
{
|
||||
spawned = None;
|
||||
if spawned_at.elapsed() < SHIM_WINDOW {
|
||||
if shared.spec.is_empty() {
|
||||
let quick = spawned_at.elapsed() < SHIM_WINDOW;
|
||||
kind = fallback_kind();
|
||||
if quick {
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and this \
|
||||
@@ -582,11 +621,10 @@ fn watch(
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by its store \
|
||||
signals instead"
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game another way"
|
||||
);
|
||||
kind = LeaseKind::Matched;
|
||||
} else if shared.spec.is_empty() {
|
||||
} else if matches!(kind, LeaseKind::Untracked) {
|
||||
// It ran long enough to have BEEN the game, and nothing else identifies it.
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -604,31 +642,30 @@ fn watch(
|
||||
shared.forget_child();
|
||||
if quick && status.success() {
|
||||
// A launcher that handed the game off and exited. Fall back to recognizing
|
||||
// the game by its store's signals; with none, stop tracking entirely rather
|
||||
// than pretend the shim's exit was the game's.
|
||||
kind = if shared.spec.is_empty() {
|
||||
// the game by its store's signals (or its provider's reporting); with
|
||||
// neither, stop tracking entirely rather than pretend the shim's exit was
|
||||
// the game's.
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and \
|
||||
this title has no detect signals — stopping game tracking for it"
|
||||
);
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by \
|
||||
its store signals instead"
|
||||
);
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game \
|
||||
another way"
|
||||
);
|
||||
} else {
|
||||
// It ran long enough to have BEEN the game (or failed outright). Either way
|
||||
// the game is gone; only a success after a real run counts as "played".
|
||||
if shared.spec.is_empty() {
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
if spawned_at.elapsed() >= SHIM_WINDOW {
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -642,11 +679,7 @@ fn watch(
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
|
||||
child = None;
|
||||
kind = if shared.spec.is_empty() {
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
LeaseKind::Matched
|
||||
};
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
@@ -680,7 +713,12 @@ fn watch(
|
||||
&& (child.is_some() || spawned.is_some())
|
||||
&& spawned_at.elapsed() >= SHIM_WINDOW;
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
// A provider saying so is as good as seeing it — better, for a title there is nothing to
|
||||
// see: it is the launcher that started the game telling us it did. This is the only way a
|
||||
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
|
||||
// gets there sooner than the scan would.
|
||||
let said_running = reported().is_some_and(|l| l.running);
|
||||
if !live.is_empty() || child_alive || said_running {
|
||||
known = live.clone();
|
||||
publish(&live);
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
@@ -754,6 +792,27 @@ fn watch(
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else if let Some(said) = reported() {
|
||||
// Nothing of the game is visible to us, but its provider is still reporting on it — and
|
||||
// that report is decisive in BOTH directions, where `running_hint` below may only ever
|
||||
// delay an exit.
|
||||
//
|
||||
// The difference is what backs each claim. Steam's registry flag is a leftover that
|
||||
// survives an unclean exit, so believing it indefinitely produces a session that never
|
||||
// ends; a provider report is an event from the launcher that started the game, restated
|
||||
// continuously, and it stops counting the moment it goes stale
|
||||
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
|
||||
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
|
||||
// session open for a game the host cannot see at all, which is the entire point for a
|
||||
// title with no detect signals, and a dead one costs at most one TTL.
|
||||
if said.running {
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else {
|
||||
finish(&shared, &on_exit, "its provider reported the game stopped");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
|
||||
// the veto below — letting it run on is exactly what bounds the veto.
|
||||
@@ -909,7 +968,7 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
"released the nested session's kept display to end its game"
|
||||
);
|
||||
}
|
||||
LeaseKind::Child | LeaseKind::Matched => {
|
||||
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
|
||||
#[cfg(target_os = "linux")]
|
||||
unix_term_ladder(shared);
|
||||
#[cfg(windows)]
|
||||
@@ -919,6 +978,26 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
|
||||
/// time, or `None`.
|
||||
///
|
||||
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
|
||||
/// nothing by construction, so without this "End" would have no target and would silently do
|
||||
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
|
||||
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
|
||||
/// pid the kernel has since recycled, contributes nothing.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
let pid = shared
|
||||
.game
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(crate::runstate::opinion)
|
||||
.filter(|l| l.running)?
|
||||
.pid?;
|
||||
crate::procscan::resolve(pid)
|
||||
}
|
||||
|
||||
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
|
||||
///
|
||||
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
|
||||
@@ -942,11 +1021,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
|
||||
unsafe { libc::kill(target, sig) == 0 }
|
||||
};
|
||||
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
|
||||
// which for a `Reported` lease is the only member of this set.
|
||||
let targets = || {
|
||||
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
if !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
}
|
||||
procs
|
||||
};
|
||||
let signal_matched = |sig: i32| -> usize {
|
||||
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
||||
// sweep is never hit.
|
||||
scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.alive(&targets())
|
||||
.into_iter()
|
||||
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
||||
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
||||
@@ -965,9 +1055,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
let deadline = Instant::now() + TERM_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(POLL);
|
||||
let still = scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.len();
|
||||
let still = scanner.alive(&targets()).len();
|
||||
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
||||
let child_gone = !signal_child(0);
|
||||
if still == 0 && child_gone {
|
||||
@@ -1000,11 +1088,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
|
||||
let live = || {
|
||||
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
|
||||
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
|
||||
// de-duplicated: the matcher may well have found this same process by its image.
|
||||
if let Some(p) = shared.spawned {
|
||||
// de-duplicated: the matcher may well have found this same process by its image. The
|
||||
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
|
||||
// only thing here (see `reported_proc`).
|
||||
let mut fold = |p: crate::procscan::ProcRef| {
|
||||
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
};
|
||||
if let Some(p) = shared.spawned {
|
||||
fold(p);
|
||||
}
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
fold(p);
|
||||
}
|
||||
procs
|
||||
};
|
||||
@@ -1570,6 +1666,54 @@ mod tests {
|
||||
assert!(!l.shared().is_trackable());
|
||||
}
|
||||
|
||||
/// A title with nothing to scan for is tracked after all when its provider reports on it.
|
||||
///
|
||||
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
|
||||
/// manually added one, a library plugin that records no install directory. The launch is a
|
||||
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
|
||||
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
|
||||
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
|
||||
#[test]
|
||||
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
|
||||
// The same request with no provider reporting: unchanged, and the control for what follows.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
assert!(!l.shared().is_trackable());
|
||||
drop(l);
|
||||
|
||||
// A provider that speaks for the title — while reporting it NOT running, which is exactly
|
||||
// what a report looks like at the moment a game is launched. Trackability follows from the
|
||||
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
|
||||
// the answer would make both lifetime behaviours depend on a plugin's timing.
|
||||
crate::runstate::report(
|
||||
"playnite-lease-test",
|
||||
["playnite:lease-test".to_string()].into_iter().collect(),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
|
||||
assert!(
|
||||
l.shared().is_trackable(),
|
||||
"so its exit is noticed and `POST /game/end` has a target"
|
||||
);
|
||||
drop(l);
|
||||
crate::runstate::forget("playnite-lease-test");
|
||||
|
||||
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
|
||||
// its plugin is the one way this could hold a session open forever.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untracked_lease_is_never_terminated() {
|
||||
let l = open(
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::InputEvent;
|
||||
use punktfunk_core::input::{GamepadEvent, InputEvent};
|
||||
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
|
||||
/// backend builds it.
|
||||
///
|
||||
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
|
||||
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
|
||||
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
|
||||
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
|
||||
/// 2026-08-09 for exactly that reason; this plane kept constructing
|
||||
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
|
||||
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
|
||||
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
|
||||
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
|
||||
/// together and neither can drift again.
|
||||
///
|
||||
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
|
||||
/// backend on other platforms drops events.
|
||||
enum SessionPads {
|
||||
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
|
||||
Xusb(GamepadManager),
|
||||
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
|
||||
#[cfg(target_os = "windows")]
|
||||
Hid(crate::inject::xbox_windows::XboxWindowsManager),
|
||||
}
|
||||
|
||||
impl SessionPads {
|
||||
/// Build this session's pad manager, honoring the shared Windows backend knob.
|
||||
fn new() -> SessionPads {
|
||||
#[cfg(target_os = "windows")]
|
||||
if crate::native::gamepad::windows_xbox_hid() {
|
||||
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
|
||||
}
|
||||
SessionPads::Xusb(GamepadManager::new())
|
||||
}
|
||||
|
||||
/// Apply one decoded controller event (create/destroy by mask, then state).
|
||||
fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
|
||||
/// kernel/driver handshake until answered, so call this every tick.
|
||||
///
|
||||
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
|
||||
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
|
||||
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
|
||||
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
|
||||
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
|
||||
match self {
|
||||
SessionPads::Xusb(m) => m.pump_rumble(rumble),
|
||||
#[cfg(target_os = "windows")]
|
||||
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile the control port to the paired-client list: bound while at least one pairing
|
||||
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
|
||||
/// wherever the paired list changes — startup, pairing phase 4, unpair.
|
||||
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
|
||||
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
|
||||
// message types in the host direction.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut pads = SessionPads::new();
|
||||
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
|
||||
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
|
||||
let mut pointer = super::pen::GsPointer::new();
|
||||
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
hdr_sent = false;
|
||||
// Unplug the session's virtual pads + tablet (destroying the
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// Surface the session's enforcement-drop totals (WP13).
|
||||
drops.end_of_session();
|
||||
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
hdr_sent = false;
|
||||
pads = GamepadManager::new();
|
||||
pads = SessionPads::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
drops.end_of_session();
|
||||
}
|
||||
@@ -689,7 +748,7 @@ fn on_receive(
|
||||
detected: &mut Option<Scheme>,
|
||||
decrypt_fails: &mut u64,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
pads: &mut SessionPads,
|
||||
pointer: &mut super::pen::GsPointer,
|
||||
grants: u32,
|
||||
drops: &mut GrantDrops,
|
||||
|
||||
@@ -105,6 +105,9 @@ mod plugins;
|
||||
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||
mod procscan;
|
||||
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
|
||||
// where `procscan` can only look at the process table.
|
||||
mod runstate;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
|
||||
@@ -372,6 +372,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
library::reconcile_provider_entries,
|
||||
library::delete_provider_entries
|
||||
))
|
||||
.routes(routes!(library::report_provider_running))
|
||||
.routes(routes!(library::get_library_art))
|
||||
.routes(routes!(stats::stats_capture_start))
|
||||
.routes(routes!(stats::stats_capture_stop))
|
||||
|
||||
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
(&Method::DELETE, "/api/v1/library/custom/{}"),
|
||||
(&Method::PUT, "/api/v1/library/provider/{}"),
|
||||
(&Method::DELETE, "/api/v1/library/provider/{}"),
|
||||
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
|
||||
// report through the catalog, so a plugin can only ever speak about entries it published,
|
||||
// and the worst a defective one can do to someone else's session is nothing at all.
|
||||
(&Method::PUT, "/api/v1/library/provider/{}/running"),
|
||||
// Stats / telemetry.
|
||||
(&Method::POST, "/api/v1/stats/capture/start"),
|
||||
(&Method::POST, "/api/v1/stats/capture/stop"),
|
||||
|
||||
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
if removed > 0 {
|
||||
tracing::info!(provider, removed, "library provider entries removed");
|
||||
}
|
||||
// Its entries are gone, so its opinions about them are meaningless — and a lease must
|
||||
// never be held open by a provider that no longer exists.
|
||||
crate::runstate::forget(&provider);
|
||||
Json(ProviderRemoved { removed }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One running title in a provider's liveness report.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RunningTitle {
|
||||
/// The provider's own stable id for the title — the same key its reconcile payload uses.
|
||||
pub external_id: String,
|
||||
/// The process id the provider started for it, when it knows one. Optional, and never trusted
|
||||
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
|
||||
/// signalled, so a stale or recycled pid simply contributes nothing.
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Request body for `reportProviderRunning`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningInput {
|
||||
/// Every title of this provider's that is running **right now**. The full set, not a delta:
|
||||
/// anything absent from it is reported as stopped.
|
||||
#[serde(default)]
|
||||
pub running: Vec<RunningTitle>,
|
||||
}
|
||||
|
||||
/// The result of a liveness report.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningAccepted {
|
||||
/// How many reported titles matched an entry this provider currently publishes.
|
||||
matched: usize,
|
||||
/// How many were ignored because no such entry exists (a report that raced a reconcile).
|
||||
unknown: usize,
|
||||
/// Seconds this report stays authoritative without being restated — re-report inside it while
|
||||
/// anything is running.
|
||||
ttl_s: u64,
|
||||
}
|
||||
|
||||
/// Report which of a provider's titles are running
|
||||
///
|
||||
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
|
||||
/// recognize* a title's process, this one says *it is running now* (design §9,
|
||||
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
|
||||
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
|
||||
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
|
||||
/// game, a manually added one) could not derive at all.
|
||||
///
|
||||
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
|
||||
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
|
||||
/// report rather than drifting.
|
||||
///
|
||||
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
|
||||
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
|
||||
/// dies with a game running stops counting shortly after, and the host falls back to process
|
||||
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
|
||||
/// inside the window.
|
||||
///
|
||||
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
|
||||
/// a report may legitimately race its own reconcile.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}/running",
|
||||
tag = "library",
|
||||
operation_id = "reportProviderRunning",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
request_body = ProviderRunningInput,
|
||||
responses(
|
||||
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn report_provider_running(
|
||||
Path(provider): Path<String>,
|
||||
ApiJson(input): ApiJson<ProviderRunningInput>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
|
||||
// titles by `external_id`; a lease knows them by the library id the catalog assigned
|
||||
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
|
||||
// this authorization-safe, since a provider can only ever speak about entries it published.
|
||||
let mine: Vec<(String, String)> = crate::library::load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
|
||||
.filter_map(|e| {
|
||||
let external = e.external_id.clone()?;
|
||||
Some((external, crate::library::library_id_for(&e)))
|
||||
})
|
||||
.collect();
|
||||
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
|
||||
|
||||
let mut running = std::collections::HashMap::new();
|
||||
let mut unknown = 0usize;
|
||||
for t in &input.running {
|
||||
match mine.iter().find(|(external, _)| *external == t.external_id) {
|
||||
Some((_, id)) => {
|
||||
running.insert(id.clone(), t.pid);
|
||||
}
|
||||
None => unknown += 1,
|
||||
}
|
||||
}
|
||||
let matched = running.len();
|
||||
tracing::debug!(
|
||||
provider,
|
||||
owned = owned.len(),
|
||||
matched,
|
||||
unknown,
|
||||
"provider liveness report"
|
||||
);
|
||||
crate::runstate::report(&provider, owned, running);
|
||||
Json(ProviderRunningAccepted {
|
||||
matched,
|
||||
unknown,
|
||||
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
|
||||
@@ -1440,6 +1440,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
("PUT", "/api/v1/library/provider/{provider}", true, false),
|
||||
("DELETE", "/api/v1/library/provider/{provider}", true, false),
|
||||
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
|
||||
// and for the same reason — the host maps the report through the catalog, so a provider can
|
||||
// only ever speak about entries it published. Never the cert lane: a streaming client has
|
||||
// no titles of its own to report on.
|
||||
(
|
||||
"PUT",
|
||||
"/api/v1/library/provider/{provider}/running",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// ---- stats.
|
||||
("POST", "/api/v1/stats/capture/start", true, false),
|
||||
("POST", "/api/v1/stats/capture/stop", true, false),
|
||||
@@ -2935,3 +2945,54 @@ async fn provider_reconcile_validation() {
|
||||
let (s, _) = send(&app, del).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Liveness reporting: the provider id is validated like every other provider write, and a title
|
||||
/// the provider does not publish is *counted*, not refused.
|
||||
///
|
||||
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
|
||||
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
|
||||
/// would throw away the liveness of every other running title, which is precisely the failure the
|
||||
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
|
||||
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
|
||||
/// the case being pinned.
|
||||
#[tokio::test]
|
||||
async fn provider_running_report_validation() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |provider: &str, body: serde_json::Value| {
|
||||
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
assert!(json["error"].as_str().unwrap().contains("reserved"));
|
||||
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An unreported provider is a legitimate report of "nothing is running".
|
||||
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 0);
|
||||
assert!(json["ttl_s"].as_u64().unwrap() > 0);
|
||||
|
||||
// An id this provider does not publish is ignored, not an error.
|
||||
let (s, json) = send(
|
||||
&app,
|
||||
put(
|
||||
"playnite",
|
||||
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 1);
|
||||
|
||||
// A report leaves no opinion behind about a title nobody published, so nothing this test did
|
||||
// can hold a real lease open.
|
||||
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
|
||||
crate::runstate::forget("playnite");
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ mod compositor;
|
||||
use compositor::resolve_compositor;
|
||||
|
||||
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
|
||||
mod gamepad;
|
||||
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
|
||||
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
|
||||
/// presents the same virtual pad and has to make the same choice, from one definition.
|
||||
pub(crate) mod gamepad;
|
||||
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
|
||||
|
||||
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
|
||||
@@ -1402,6 +1404,12 @@ async fn serve_session(
|
||||
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
|
||||
// trace of why).
|
||||
let cadence_behind_score = Arc::new(AtomicU32::new(0));
|
||||
// Delivery truth, control task → data plane: the packet count the client reports having
|
||||
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
|
||||
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
|
||||
// blames the client for a stream that never reached it.
|
||||
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
|
||||
let client_packets_received_ctl = client_packets_received.clone();
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1533,6 +1541,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
client_packets_received_ctl,
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
@@ -2091,6 +2100,26 @@ async fn serve_session(
|
||||
address with no hole-punch; else punched=true → the client's observed source, \
|
||||
false → no punch seen, the reported address)"
|
||||
);
|
||||
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
|
||||
// data port the client cannot reach INBOUND, and every client punches (5/s for the
|
||||
// first three seconds, then every two). Video then goes to an address the client only
|
||||
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
|
||||
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
|
||||
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
|
||||
// this port is ephemeral and different every session (fixed by the program-scoped rule
|
||||
// `service install` now adds — an install predating it still has the old rules).
|
||||
// `direct` skips the punch by operator choice, so it is not a failure there.
|
||||
if !direct && !punched {
|
||||
tracing::warn!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
"no hole-punch reached this host's data port — inbound UDP to it looks \
|
||||
BLOCKED, so video is being sent to the address the client reported without \
|
||||
any confirmed return path. If the picture stays black while the session is \
|
||||
otherwise healthy, this line is the reason: allow inbound UDP for the host \
|
||||
executable (any port), or pin --data-port and open that one"
|
||||
);
|
||||
}
|
||||
let mut session = Session::new(cfg, Box::new(transport))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
match source {
|
||||
@@ -2125,6 +2154,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
|
||||
@@ -30,6 +30,10 @@ pub(super) async fn run(
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
cadence_behind_score: Arc<AtomicU32>,
|
||||
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
|
||||
// the packets the client says it has received all session (`u32::MAX` = a client too old to
|
||||
// send one, the pre-seeded value).
|
||||
client_packets_received: Arc<AtomicU32>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
@@ -162,6 +166,16 @@ pub(super) async fn run(
|
||||
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
|
||||
// What the client has actually RECEIVED — published unconditionally, because it
|
||||
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
|
||||
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
|
||||
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
|
||||
// Saturated into the u32 bridge; the value only ever matters near zero.
|
||||
client_packets_received.store(
|
||||
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
} else if let Ok(rep) = LossReport::decode(&msg) {
|
||||
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
|
||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||
|
||||
@@ -363,8 +363,13 @@ fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
|
||||
///
|
||||
/// The two backends are mutually exclusive per pad by construction (one match arm or the other) —
|
||||
/// presenting both would hand a game two controllers for one pair of hands.
|
||||
///
|
||||
/// Read by BOTH input planes. The native plane branches on it in `Pads::handle`; the GameStream
|
||||
/// plane in `gamestream::control::SessionPads`. It was `pub(super)` while only the native plane
|
||||
/// consulted it, and that is exactly how Moonlight sessions spent two releases on the XUSB pad
|
||||
/// after this default flipped — the knob was unreachable from the module that needed it.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn windows_xbox_hid() -> bool {
|
||||
pub(crate) fn windows_xbox_hid() -> bool {
|
||||
match std::env::var("PUNKTFUNK_XBOX_BACKEND") {
|
||||
Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false,
|
||||
// Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out
|
||||
|
||||
@@ -1319,6 +1319,14 @@ pub(super) struct SessionContext {
|
||||
/// of what held it there — the score is the missing discriminator between "the detector's
|
||||
/// budget is wrong" and "this encoder genuinely can't hold cadence").
|
||||
pub(super) cadence_behind_score: Arc<AtomicU32>,
|
||||
/// Data-plane packets the CLIENT says it has received all session, from the latest
|
||||
/// [`punktfunk_core::quic::DeliveryReport`] ([`u32::MAX`] = a client too old to send one).
|
||||
///
|
||||
/// The one signal that distinguishes "the link is clean" from "nothing is arriving": both look
|
||||
/// like `loss_ppm = 0`, because loss is a ratio over the packets that DID arrive. Read by the
|
||||
/// keyframe-cadence diagnosis below, which without it accuses the client of being too slow for
|
||||
/// a stream it has never received a byte of.
|
||||
pub(super) client_packets_received: Arc<AtomicU32>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1598,6 +1606,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -3006,16 +3015,65 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// subsystems while the real chain was: client refused the codec → demoted to
|
||||
// a slower decode rung → could not sustain the rate → standing queue.
|
||||
// Perfect periodicity argues FOR a software cooldown, not against it.
|
||||
if matches_client_flush_cadence(period) {
|
||||
tracing::warn!(
|
||||
let client_rx = client_packets_received.load(Ordering::Relaxed);
|
||||
// The client has TOLD us it has received nothing all session (a v1 client
|
||||
// leaves the `u32::MAX` seed, so this only fires on an explicit zero). That
|
||||
// outranks both cadence verdicts below, which are about a client drowning in
|
||||
// frames — the opposite failure, and indistinguishable by period alone because
|
||||
// a client that got no picture re-asks on its own no-video timer at very
|
||||
// nearly the same spacing. Diagnosing this as "too slow" cost a 2026-08-20
|
||||
// field investigation days: the host was blameless-looking (`sent` climbing,
|
||||
// `loss_ppm = 0`, FEC decayed to the floor) while not one byte of video ever
|
||||
// reached the client.
|
||||
if client_rx == 0 {
|
||||
tracing::error!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries match the client's jump-to-live cooldown \
|
||||
— the CLIENT cannot sustain the stream and is shedding a standing \
|
||||
receive queue (check its log for 'receive backlog stopped draining' \
|
||||
with queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT a \
|
||||
host display disturbance"
|
||||
frames_sent = sent,
|
||||
"THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT — it reports 0 \
|
||||
packets received all session while this host has sent the frames \
|
||||
counted here, so the picture is black and every keyframe we force is \
|
||||
wasted. The control plane is healthy (this report arrived on it), so \
|
||||
the session looks alive: audio, input and the library keep working. \
|
||||
This is a PATH problem, not decode — check that inbound UDP to this \
|
||||
host's per-session data port is allowed (the 'data plane bound' line \
|
||||
above shows `punched=false` when the client's hole-punch never \
|
||||
arrived, which is the fingerprint), and that no other host or \
|
||||
firewall is intercepting it"
|
||||
);
|
||||
} else if matches_client_recovery_cooldown(period) {
|
||||
if client_rx == u32::MAX {
|
||||
// This client predates the delivery count, so the period alone has to
|
||||
// carry the verdict — and it CANNOT: both client cooldowns live in this
|
||||
// band and they mean opposite things. Say so instead of picking one.
|
||||
// The old confident wording sent a field investigation after the
|
||||
// decoder for days while the real fault was that nothing arrived.
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
frames_sent = sent,
|
||||
"client keyframe recoveries land on a client software cooldown, \
|
||||
but this client is too old to report whether any video reached \
|
||||
it — so this is EITHER a client that cannot sustain the stream \
|
||||
and is shedding a standing receive queue, OR a client that has \
|
||||
received nothing at all and is re-asking on its no-video timer. \
|
||||
They are opposite faults; the host cannot tell them apart from \
|
||||
the period. Its log does: 'receive backlog stopped draining' \
|
||||
(with queue_depth) means the first, 'no video received … into \
|
||||
the session' means the second. Upgrading the client makes this \
|
||||
line decide on its own"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
client_packets_received = client_rx,
|
||||
"client keyframe recoveries match the client's jump-to-live \
|
||||
cooldown, and it confirms video IS arriving — the CLIENT cannot \
|
||||
sustain the stream and is shedding a standing receive queue \
|
||||
(check its log for 'receive backlog stopped draining' with \
|
||||
queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT \
|
||||
a host display disturbance"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
@@ -4191,6 +4249,26 @@ fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
|
||||
period.abs_diff(flush) < flush / 10
|
||||
}
|
||||
|
||||
/// The client's OTHER re-ask cooldown: it has received no video whatsoever and is asking for a
|
||||
/// keyframe on its no-video timer. Kept separate from [`matches_client_flush_cadence`] because the
|
||||
/// two describe opposite faults — drowning in frames versus receiving none — and only the client's
|
||||
/// reported delivery count can say which. Both are host-side-irrelevant either way: a fixed
|
||||
/// software cooldown is never the periodic *disturbance* the metronomic branch reports.
|
||||
///
|
||||
/// Compared against the SHARED constant, never a copy of the number — the same discipline
|
||||
/// [`matches_client_flush_cadence`] follows, and the one that was missing when the two cooldowns
|
||||
/// were both 2000 ms and the host could not even tell that it was guessing.
|
||||
fn matches_client_no_video_cadence(period: std::time::Duration) -> bool {
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
period.abs_diff(no_video) < no_video / 10
|
||||
}
|
||||
|
||||
/// Either client cooldown — the band in which a period tells us about the CLIENT's software, not
|
||||
/// about anything physical on this host.
|
||||
fn matches_client_recovery_cooldown(period: std::time::Duration) -> bool {
|
||||
matches_client_flush_cadence(period) || matches_client_no_video_cadence(period)
|
||||
}
|
||||
|
||||
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||
type Pipeline = (
|
||||
@@ -5068,6 +5146,29 @@ mod tests {
|
||||
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
/// The two client cooldowns must stay TELLABLE APART by period, and both must stay out of the
|
||||
/// display-disturbance branch. While they were both 2000 ms a black-screen field case (nothing
|
||||
/// ever reached the client) was reported as "the client cannot sustain the stream" — the exact
|
||||
/// opposite fault — because the periods were identical and the host guessed.
|
||||
#[test]
|
||||
fn the_two_client_cooldowns_are_distinguishable_and_both_excluded_from_display_blame() {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
assert_ne!(
|
||||
flush, no_video,
|
||||
"identical cooldowns make the host's verdict a coin flip"
|
||||
);
|
||||
// Neither may fall inside the other's ±10% band, or the period stops discriminating.
|
||||
assert!(!matches_client_flush_cadence(no_video));
|
||||
assert!(!matches_client_no_video_cadence(flush));
|
||||
// Both are client software cooldowns: never the metronomic display-disturbance branch.
|
||||
assert!(matches_client_recovery_cooldown(flush));
|
||||
assert!(matches_client_recovery_cooldown(no_video));
|
||||
// A real periodic disturbance still reaches that branch.
|
||||
assert!(!matches_client_recovery_cooldown(flush * 3));
|
||||
assert!(!matches_client_recovery_cooldown(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! What a provider plugin **says** is running — the one liveness signal the host cannot work out
|
||||
//! for itself.
|
||||
//!
|
||||
//! [`crate::procscan`] answers "is this game running" by looking at the process table, and
|
||||
//! [`crate::gamelease`] turns that into a session lifetime. That works because most stores leave
|
||||
//! something recognizable behind: an install directory, an executable, a Steam reaper. Some do not,
|
||||
//! and one store in particular *already knows the answer*: Playnite starts the game itself, tracks
|
||||
//! it with the mode the person configured (process, directory, original-process), and fires an
|
||||
//! event on both edges — carrying the pid it started. Every bit of that was being thrown away, and
|
||||
//! the host was left re-deriving a worse version of it by scanning.
|
||||
//!
|
||||
//! So this is the inbound half of [`crate::library::DetectHint`]. That one is *static* ("here is
|
||||
//! how to recognize my title's process"); this one is *live* ("that title is running right now, and
|
||||
//! here is its pid"). A provider PUTs its full running set; the host keeps it here; the lease
|
||||
//! watcher consults it.
|
||||
//!
|
||||
//! ### Why the whole set, and why a TTL
|
||||
//!
|
||||
//! The wire is declarative — the same shape as the library reconcile, for the same reason. A
|
||||
//! provider that missed an event, restarted, or was installed mid-game converges on its next PUT
|
||||
//! instead of drifting forever; there is no per-event delta to lose.
|
||||
//!
|
||||
//! And a report **expires**. A plugin that dies with a game running would otherwise leave a claim
|
||||
//! that is true today and a lie tomorrow — and unlike Steam's registry flag (which
|
||||
//! [`crate::procscan::running_hint`] must treat as merely a bounded veto because Steam leaves it
|
||||
//! set on any unclean exit) this claim is allowed to *keep a session alive on its own*. That is
|
||||
//! only safe while something is actively restating it, so a report older than [`REPORT_TTL`] stops
|
||||
//! counting and the host falls back to scanning, exactly as it does today. The provider's side of
|
||||
//! that bargain is to re-PUT well inside the window while anything is running.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a provider's report stays authoritative without being restated.
|
||||
///
|
||||
/// Generous enough that a plugin refreshing every 30s survives a slow reconcile or a paused runner,
|
||||
/// short enough that a *dead* plugin stops vetoing a session end within a couple of minutes. The
|
||||
/// cost of expiring too early is the pre-existing behaviour (scan-only); the cost of never expiring
|
||||
/// is a session that can never end on its own, which is the bug this whole area exists to kill.
|
||||
pub const REPORT_TTL: Duration = Duration::from_secs(90);
|
||||
|
||||
/// What a provider says about one of its titles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Liveness {
|
||||
/// Whether the provider lists this title as running right now.
|
||||
pub running: bool,
|
||||
/// The pid the provider started for it, when it knows one. Never trusted as a bare number —
|
||||
/// every use re-verifies it through [`crate::procscan`], which pins it to its start time.
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// One provider's most recent report.
|
||||
struct Report {
|
||||
/// When it landed — the TTL clock.
|
||||
at: Instant,
|
||||
/// Every library id this provider speaks for. What makes "not in `running`" mean *not running*
|
||||
/// rather than *no opinion*: without it an omitted title is indistinguishable from a title
|
||||
/// belonging to some other provider entirely.
|
||||
owned: HashSet<String>,
|
||||
/// The subset that is running, each with the pid the provider started (when it has one).
|
||||
running: HashMap<String, Option<u32>>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn fresh(&self) -> bool {
|
||||
self.at.elapsed() < REPORT_TTL
|
||||
}
|
||||
}
|
||||
|
||||
fn table() -> MutexGuard<'static, HashMap<String, Report>> {
|
||||
static TABLE: OnceLock<Mutex<HashMap<String, Report>>> = OnceLock::new();
|
||||
TABLE
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Record a provider's report, replacing whatever it said before.
|
||||
///
|
||||
/// `owned` is every library id the provider currently publishes; `running` is the subset that is
|
||||
/// running, keyed the same way, valued by pid where one is known.
|
||||
pub fn report(provider: &str, owned: HashSet<String>, running: HashMap<String, Option<u32>>) {
|
||||
table().insert(
|
||||
provider.to_string(),
|
||||
Report {
|
||||
at: Instant::now(),
|
||||
owned,
|
||||
running,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Forget everything a provider said — its entries are gone, so its opinions are meaningless.
|
||||
pub fn forget(provider: &str) {
|
||||
table().remove(provider);
|
||||
}
|
||||
|
||||
/// What a *fresh* provider says about this library id, or `None` when none speaks for it.
|
||||
///
|
||||
/// `None` is the answer for every title on a host with no reporting plugin, which is what keeps
|
||||
/// this entirely inert until someone opts in.
|
||||
pub fn opinion(app_id: &str) -> Option<Liveness> {
|
||||
let table = table();
|
||||
table
|
||||
.values()
|
||||
.filter(|r| r.fresh())
|
||||
.find(|r| r.owned.contains(app_id))
|
||||
.map(|r| match r.running.get(app_id) {
|
||||
Some(pid) => Liveness {
|
||||
running: true,
|
||||
pid: *pid,
|
||||
},
|
||||
None => Liveness {
|
||||
running: false,
|
||||
pid: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any fresh provider reports liveness for this title at all — regardless of what it
|
||||
/// currently says.
|
||||
///
|
||||
/// Asked once, when a lease opens: a title whose provider will tell us when it stops is trackable
|
||||
/// even with no detect signals whatsoever, which is the whole point (see
|
||||
/// [`crate::gamelease::LeaseKind::Reported`]).
|
||||
pub fn speaks_for(app_id: Option<&str>) -> bool {
|
||||
app_id.is_some_and(|id| opinion(id).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn owned(ids: &[&str]) -> HashSet<String> {
|
||||
ids.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn running(ids: &[(&str, Option<u32>)]) -> HashMap<String, Option<u32>> {
|
||||
ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect()
|
||||
}
|
||||
|
||||
// The table is process-global and these tests run in parallel, so each takes a provider id and
|
||||
// app ids only it uses, and cleans up only its own row. An earlier draft shared the id
|
||||
// `playnite` and cleared the whole table between cases, which made the three of them flip each
|
||||
// other's answers depending on scheduling — the same shape as `mgmt`'s `local_summary` race.
|
||||
|
||||
/// The three answers, and the distinction the whole module turns on: a title its provider omits
|
||||
/// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would
|
||||
/// make every unreported game on the box look like it had just quit.
|
||||
#[test]
|
||||
fn omitted_is_not_running_but_unknown_is_no_opinion() {
|
||||
report(
|
||||
"answers-test",
|
||||
owned(&["answers:a", "answers:b"]),
|
||||
running(&[("answers:a", Some(4242))]),
|
||||
);
|
||||
assert_eq!(
|
||||
opinion("answers:a"),
|
||||
Some(Liveness {
|
||||
running: true,
|
||||
pid: Some(4242)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
opinion("answers:b"),
|
||||
Some(Liveness {
|
||||
running: false,
|
||||
pid: None
|
||||
})
|
||||
);
|
||||
assert_eq!(opinion("answers:never-published"), None);
|
||||
assert!(speaks_for(Some("answers:b")));
|
||||
assert!(!speaks_for(Some("answers:never-published")));
|
||||
assert!(!speaks_for(None));
|
||||
forget("answers-test");
|
||||
}
|
||||
|
||||
/// A report replaces its predecessor wholesale. The set is the message: a title that dropped out
|
||||
/// of it has stopped, and carrying the old entry forward would be exactly the stuck-running
|
||||
/// state this exists to prevent.
|
||||
#[test]
|
||||
fn a_report_replaces_the_previous_one() {
|
||||
report(
|
||||
"replace-test",
|
||||
owned(&["replace:a"]),
|
||||
running(&[("replace:a", None)]),
|
||||
);
|
||||
report("replace-test", owned(&["replace:a"]), running(&[]));
|
||||
assert_eq!(
|
||||
opinion("replace:a"),
|
||||
Some(Liveness {
|
||||
running: false,
|
||||
pid: None
|
||||
})
|
||||
);
|
||||
forget("replace-test");
|
||||
assert_eq!(opinion("replace:a"), None);
|
||||
}
|
||||
|
||||
/// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a
|
||||
/// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds.
|
||||
#[test]
|
||||
fn a_stale_report_has_no_opinion() {
|
||||
table().insert(
|
||||
"stale-test".to_string(),
|
||||
Report {
|
||||
at: Instant::now() - REPORT_TTL - Duration::from_secs(1),
|
||||
owned: owned(&["stale:a"]),
|
||||
running: running(&[("stale:a", Some(7))]),
|
||||
},
|
||||
);
|
||||
assert_eq!(opinion("stale:a"), None);
|
||||
assert!(!speaks_for(Some("stale:a")));
|
||||
forget("stale-test");
|
||||
}
|
||||
}
|
||||
@@ -1587,6 +1587,7 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
|
||||
}
|
||||
}
|
||||
add_data_plane_firewall_rule(profile);
|
||||
if !allow_public {
|
||||
println!(
|
||||
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
|
||||
@@ -1596,7 +1597,75 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule name for the program-scoped data-plane rule (see [`add_data_plane_firewall_rule`]).
|
||||
const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
|
||||
|
||||
/// Inbound UDP for the host executable itself, at **any** local port.
|
||||
///
|
||||
/// The media data plane binds an EPHEMERAL port per session (`0.0.0.0:0`, reported to the client in
|
||||
/// the Welcome), so no `localport=` rule can cover it — the port-scoped rules above open the fixed
|
||||
/// control/GameStream/mDNS ports and nothing else. Without this, Windows Firewall drops the client's
|
||||
/// hole-punch (`PUNCH_MAGIC` → the host's data port) on EVERY session: that is what `punched=false`
|
||||
/// on the host's "data plane bound" line means. The punch then never opens the return path, video
|
||||
/// falls back to blind-sending at the address the client merely *reported*, and the moment anything
|
||||
/// on the path needs the flow opened client-first the stream goes black while the control plane
|
||||
/// stays healthy — no reconnect, no error, just a session that never shows a picture.
|
||||
///
|
||||
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
|
||||
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
|
||||
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
|
||||
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
|
||||
fn add_data_plane_firewall_rule(profile: &str) {
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: could not resolve the host executable path ({e}) — skipping the \
|
||||
data-plane firewall rule; streams may show a black picture behind a healthy \
|
||||
connection on networks that need the client's hole-punch to open the path"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ok = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
&format!("name={FW_DATA_PLANE_RULE}"),
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
"protocol=UDP",
|
||||
&format!("program={}", exe.to_string_lossy()),
|
||||
profile,
|
||||
],
|
||||
);
|
||||
if ok {
|
||||
println!(
|
||||
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
|
||||
exe.display()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"warning: could not add firewall rule '{FW_DATA_PLANE_RULE}' — the per-session video \
|
||||
data port stays closed to inbound, so the client's hole-punch cannot reach it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
let _ = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
&format!("name={FW_DATA_PLANE_RULE}"),
|
||||
],
|
||||
);
|
||||
for suffix in ["TCP", "UDP"] {
|
||||
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
|
||||
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
|
||||
|
||||
@@ -230,13 +230,15 @@ How the client itself looks. None touches a stream, so none can live in a
|
||||
|
||||
**Gamepad-optimized browsing** — *default: on.* Swaps the touch/desktop home for the
|
||||
controller-optimized one: host carousel, larger focus targets, a swipeable cover browser, steppable
|
||||
settings. Apple and Android have the switch; on Linux, Windows and the Steam Deck the
|
||||
controller-optimized home is a separate entry point. An Android TV is always in this mode.
|
||||
settings. Apple and Android have the switch — on Android in both places, ordinary Settings and the
|
||||
controller-optimized settings themselves, so that home can be left from inside it; on Linux, Windows
|
||||
and the Steam Deck the controller-optimized home is a separate entry point. An Android TV is always
|
||||
in this mode, so the switch is not offered there.
|
||||
|
||||
**Show it** — *default: With a controller.* Shown while the switch above is on. **With a
|
||||
controller**: the controller-optimized home appears as a pad connects, the touch interface returns
|
||||
when the last one disconnects. **Always** keeps it either way — for a phone or tablet docked to a
|
||||
TV. Apple and Android (an Android TV is in that mode regardless).
|
||||
TV. Apple and Android (an Android TV is in that mode regardless, so the row is not offered there).
|
||||
|
||||
**Background** — *default: Violet.* The colour family of the controller-optimized home's backdrop.
|
||||
Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**, **Moss**,
|
||||
@@ -244,9 +246,11 @@ Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**,
|
||||
flip the interface to dark text on a light field. The backdrop recolours as you step the row.
|
||||
**OLED** is true black: most of the frame is pixels switched off — no glow, no power on an
|
||||
OLED/AMOLED panel. Stored under the same name on every client. The row lives in the
|
||||
controller-optimized settings (**X** from the controller-optimized home) everywhere that has one,
|
||||
including the Steam Deck and the Linux/Windows console home; the Apple TV carries it in ordinary
|
||||
Settings next to **Show it** instead, so it's reachable from the Siri Remote.
|
||||
controller-optimized settings (**X**, or **down** on the host carousel, from the controller-optimized
|
||||
home) everywhere that has one, including the Steam Deck and the Linux/Windows console home — down is
|
||||
the route where there are no face buttons to press, such as an Android TV remote, and the hint bar
|
||||
names whichever your device has; the Apple TV carries it in ordinary Settings next to **Show it**
|
||||
instead, so it's reachable from the Siri Remote.
|
||||
|
||||
## Overlay
|
||||
|
||||
|
||||
@@ -137,8 +137,9 @@ runs what it already knows about the title, so a client can never hand the host
|
||||
keep a **Show game library** switch, on by default, for turning it off. See
|
||||
[Client settings](/docs/client-settings).
|
||||
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
|
||||
options and choose **Library**.
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or press
|
||||
**up** for its options and choose **Library** — the route a TV remote takes, having no **Y** to
|
||||
press.
|
||||
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
|
||||
Punktfunk**, which opens the client's console home, where a paired host's **Library** button is —
|
||||
full-screen covers, gamepad-navigable, and a press starts the stream with the title launching. See
|
||||
|
||||
@@ -1860,6 +1860,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7792,6 +7855,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +7949,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -1224,6 +1224,11 @@
|
||||
#define PUNKTFUNK_MSG_PIPELINE_GAP 10
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`DeliveryReport`].
|
||||
#define PUNKTFUNK_MSG_DELIVERY_REPORT 11
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
|
||||
|
||||
@@ -31,11 +31,39 @@
|
||||
{
|
||||
lib,
|
||||
gamescope,
|
||||
fetchFromGitHub,
|
||||
python3,
|
||||
patchDir,
|
||||
manifestRewriter,
|
||||
}:
|
||||
let
|
||||
# PIN THE COMPOSITOR SOURCE, rather than patching whatever gamescope nixpkgs happens to carry.
|
||||
# Every other channel already ships this exact commit — packaging/gamescope/README.md,
|
||||
# punktfunk-gamescope.spec, the PKGBUILD and build-punktfunk-gamescope.sh — and nix was the
|
||||
# only one tracking nixpkgs' version and hoping ten patches still applied.
|
||||
#
|
||||
# They did not, and the failures were not academic (MEASURED 2026-08-19/20):
|
||||
# * nixpkgs shipped 3.16.24 and patch 0009's context did not exist there at all, so the
|
||||
# build died at patchPhase — every `services.punktfunk.host.enable = true` with it.
|
||||
# * bumping the lock to 3.16.25 fixed that, then `--version` printed NOTHING: upstream's
|
||||
# `gamescope::PrintVersion()` landed AFTER the 3.16.25 tag. The host reads that banner to
|
||||
# decide a session's bit depth and cursor compositing BEFORE the virtual display exists,
|
||||
# so a silent banner means a silent fall back to SDR — the exact failure every guard in
|
||||
# this file is written to prevent.
|
||||
# Both are the same bug: nixpkgs' gamescope is older than the tree these patches target.
|
||||
# Pinning makes the nix package agree with every other channel byte for byte.
|
||||
#
|
||||
# Bumping this: move the rev, then `nix-prefetch-git --url https://github.com/ValveSoftware/gamescope
|
||||
# --rev <new> --fetch-submodules` for the hash, and keep packaging/gamescope/README.md in step.
|
||||
pfRev = "5fb8dce4a09d0a68d097b9faf9513782106bc843";
|
||||
pfVersion = "3.16.25-11-g5fb8dce";
|
||||
pfSrc = fetchFromGitHub {
|
||||
owner = "ValveSoftware";
|
||||
repo = "gamescope";
|
||||
rev = pfRev;
|
||||
fetchSubmodules = true;
|
||||
hash = "sha256-pGBiO+7LSdIc0k9K+SQnv/Og2DYD/cjvOImxIl91L2A=";
|
||||
};
|
||||
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
|
||||
# "gamescope", version 3.16.25, carrying `src`/`patches`/`mesonFlags`. Revisions that wrap it
|
||||
# (to wire the WSI layer + capabilities) expose the build as `.unwrapped`, so prefer that where
|
||||
@@ -76,6 +104,8 @@ let
|
||||
in
|
||||
unwrapped.overrideAttrs (old: {
|
||||
pname = "punktfunk-gamescope";
|
||||
version = pfVersion;
|
||||
src = pfSrc;
|
||||
|
||||
# Read the patch DIRECTORY rather than naming files: `builtins.attrNames` sorts
|
||||
# lexicographically, which for `000N-` prefixes is exactly the apply order, and a patch added or
|
||||
@@ -96,7 +126,19 @@ unwrapped.overrideAttrs (old: {
|
||||
substituteInPlace src/meson.build \
|
||||
--replace-fail \
|
||||
"vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()" \
|
||||
"vcs_tag = '${old.version}'"
|
||||
"vcs_tag = '${pfVersion}'"
|
||||
|
||||
# Source-level gate, the same one packaging/gamescope/build-punktfunk-gamescope.sh applies.
|
||||
# Splits a missing marker into its two possible stages: fire HERE and patch 0005 or the
|
||||
# substitution above lost it; pass here and fail the ELF check later, and it was lost in
|
||||
# meson configuration or compilation instead. Without this the two are indistinguishable,
|
||||
# at a full compositor build per guess.
|
||||
grep -q '+pfhdr' src/meson.build || {
|
||||
echo "punktfunk-gamescope: +pfhdr is not in src/meson.build after patching" >&2
|
||||
echo " --- version block as patched: ---" >&2
|
||||
sed -n '/^vcs_tag_cmd/,/^gamescope_version_conf/p' src/meson.build | sed 's/^/ | /' >&2
|
||||
exit 1
|
||||
}
|
||||
'';
|
||||
|
||||
# Ship the compositor, renamed, AND the WSI layer built beside it. Everything else nixpkgs
|
||||
@@ -134,7 +176,17 @@ unwrapped.overrideAttrs (old: {
|
||||
chmod -R u+w $out
|
||||
|
||||
find $out -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} +
|
||||
find $out/bin -mindepth 1 ! -name gamescope -delete
|
||||
# KEEP `.gamescope-wrapped`. nixpkgs wraps this package: makeWrapper leaves the real
|
||||
# compositor ELF at bin/.gamescope-wrapped and installs a small launcher at bin/gamescope
|
||||
# that sets PATH (xwininfo) before exec'ing it. A prune that keeps only `gamescope` deletes
|
||||
# the compositor and ships the launcher alone — MEASURED 2026-08-20 (run 19622): $out/bin
|
||||
# held a single 16 KB file, `--version` printed nothing because the launcher exec'd a path
|
||||
# that no longer existed, and no +pfhdr marker was present because a wrapper carries no
|
||||
# version string. Every symptom chased for three builds came from this one line.
|
||||
#
|
||||
# The launcher references its target by ABSOLUTE path, so renaming the launcher is safe
|
||||
# while the target keeps its name.
|
||||
find $out/bin -mindepth 1 ! -name gamescope ! -name '.gamescope-wrapped' -delete
|
||||
mv $out/bin/gamescope $out/bin/punktfunk-gamescope
|
||||
|
||||
install -Dm0755 "$TMPDIR/pf-layer.so" \
|
||||
@@ -147,8 +199,38 @@ unwrapped.overrideAttrs (old: {
|
||||
doInstallCheck = true;
|
||||
installCheckPhase = ''
|
||||
runHook preInstallCheck
|
||||
$out/bin/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' \
|
||||
|| { echo "punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take"; exit 1; }
|
||||
# Assert the marker is compiled INTO the shipped binary, rather than running it.
|
||||
#
|
||||
# Running it does not work here and never did: `--version` produced EMPTY output under the
|
||||
# build sandbox on BOTH nixpkgs' 3.16.25 and the pinned 5fb8dce4 (MEASURED 2026-08-19/20,
|
||||
# runs 19551 / 19573 / 19594). That is a property of the sandbox, not a defect in the binary:
|
||||
# gamescope calls PrintVersion() before the getopt loop (src/main.cpp:721 at the pinned rev),
|
||||
# so `gamescope --version` DOES print the banner on a real system — which is what the host's
|
||||
# capability probe reads.
|
||||
#
|
||||
# packaging/gamescope/build-punktfunk-gamescope.sh makes the same call, asserting on
|
||||
# src/meson.build. Grepping the installed ELF is strictly stronger: the version string reaches
|
||||
# .rodata through GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
|
||||
# patching, meson configuration AND compilation into the artifact we actually ship, and it
|
||||
# cannot be defeated by the binary being unable to start.
|
||||
# Grep the WRAPPED ELF: bin/punktfunk-gamescope is nixpkgs' launcher and carries no version
|
||||
# string at all, so asserting on it would pass only by accident. Fall back to the launcher
|
||||
# for a future nixpkgs that stops wrapping.
|
||||
gsElf=$out/bin/.gamescope-wrapped
|
||||
[ -f "$gsElf" ] || gsElf=$out/bin/punktfunk-gamescope
|
||||
grep -aq '+pfhdr' "$gsElf" || {
|
||||
echo "punktfunk-gamescope: the +pfhdr marker is not in the installed binary." >&2
|
||||
echo " src/meson.build carried it (asserted in postPatch), so it was lost between" >&2
|
||||
echo " meson configuration and the linked artifact. Evidence:" >&2
|
||||
echo " --- $out/bin ---" >&2
|
||||
ls -l $out/bin 2>&1 | sed 's/^/ | /' >&2
|
||||
echo " --- anything under $out mentioning pfhdr ---" >&2
|
||||
grep -ral 'pfhdr' $out 2>/dev/null | sed 's/^/ | /' >&2 || echo " | (nothing)" >&2
|
||||
echo " --- version-ish strings in the binary ---" >&2
|
||||
grep -aoE '[0-9]+\.[0-9]+\.[0-9]+[^ ]*' "$gsElf" 2>/dev/null \
|
||||
| sort -u | head -5 | sed 's/^/ | /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
# The manifest must name a library this derivation actually installed. A manifest pointing at a
|
||||
# path that does not exist is the worst shape of this bug: the loader reads it, finds nothing,
|
||||
# and carries on silently, so the box looks healthy and every game renders SDR.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ export {
|
||||
} from "./paths.js";
|
||||
export {
|
||||
Artwork,
|
||||
DEFAULT_RUNNING_TTL_S,
|
||||
DetectHint,
|
||||
GameMeta,
|
||||
LaunchSpec,
|
||||
@@ -29,6 +30,8 @@ export {
|
||||
ProviderClient,
|
||||
type ProviderClientService,
|
||||
ProviderEntry,
|
||||
type RunningAccepted,
|
||||
type RunningTitle,
|
||||
} from "./reconcile.js";
|
||||
export {
|
||||
definePluginKit,
|
||||
|
||||
@@ -9,6 +9,14 @@ import type { ProviderEntry } from "./wire.js";
|
||||
|
||||
export * from "./wire.js";
|
||||
|
||||
/**
|
||||
* The host's liveness-report TTL, in seconds, when it does not say.
|
||||
*
|
||||
* Only a fallback for parsing an unexpected answer — the authority is the `ttlS` the host returns.
|
||||
* A reporter should refresh at a fraction of this, so one missed call is not a lapse.
|
||||
*/
|
||||
export const DEFAULT_RUNNING_TTL_S = 90;
|
||||
|
||||
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
|
||||
export interface ReconciledEntry {
|
||||
readonly id: string;
|
||||
@@ -35,6 +43,36 @@ export interface ProviderClientService {
|
||||
entries: ReadonlyArray<ProviderEntry>,
|
||||
store?: string,
|
||||
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
|
||||
/**
|
||||
* Report which of this provider's titles are running **right now** — the live counterpart to the
|
||||
* static `detect` hints in {@link reconcile}.
|
||||
*
|
||||
* `detect` says *how to recognize* a title's process; this says *it is running*, and carries the
|
||||
* pid where the provider knows one. For a launcher that starts games itself and is told when
|
||||
* they stop, this is a fact the host would otherwise re-derive by scanning — and for a title
|
||||
* with nothing to scan for (an emulated game, a manually added one, a launcher that records no
|
||||
* install directory) could not derive at all: its lease is `untracked`, its exit is never
|
||||
* noticed, and the streaming session outlives the game.
|
||||
*
|
||||
* **Send the complete set, not a delta.** Anything absent is reported stopped, so a missed
|
||||
* event, a plugin restart or an install mid-game all self-correct on the next call.
|
||||
*
|
||||
* **The host expires a report** (`ttlS` in the answer, 90s at the time of writing) unless it is
|
||||
* restated — which is what makes it safe for the host to keep a session open for a game it
|
||||
* cannot see. Call this on every change **and** on a timer well inside that window while
|
||||
* anything is running; a plugin that stops reporting simply hands tracking back to the host's
|
||||
* process scan.
|
||||
*
|
||||
* Titles the host has no entry for are counted in `unknown`, not refused: a report may
|
||||
* legitimately race its own reconcile.
|
||||
*
|
||||
* Fails on a host that predates the route (404) — treat that as "this host tracks games by
|
||||
* scanning" and carry on, exactly as with any other optional capability.
|
||||
*/
|
||||
readonly reportRunning: (
|
||||
providerId: string,
|
||||
running: ReadonlyArray<RunningTitle>,
|
||||
) => Effect.Effect<RunningAccepted, HostRequestError>;
|
||||
/**
|
||||
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
|
||||
* path). Releasing is what brings the host's built-in scanner back.
|
||||
@@ -44,6 +82,29 @@ export interface ProviderClientService {
|
||||
) => Effect.Effect<void, HostRequestError>;
|
||||
}
|
||||
|
||||
/** One running title in a {@link ProviderClientService.reportRunning} call. */
|
||||
export interface RunningTitle {
|
||||
/** The provider's own stable id — the same key its reconcile payload uses. */
|
||||
readonly external_id: string;
|
||||
/**
|
||||
* The process the provider started for it, when it knows one. Optional, and never trusted as a
|
||||
* bare number: the host re-resolves it and pins it to its start time before it is ever
|
||||
* signalled, so a stale or recycled pid contributes nothing. Worth sending anyway — it is what
|
||||
* gives "End game" something to aim at for a title the host's matcher cannot find.
|
||||
*/
|
||||
readonly pid?: number;
|
||||
}
|
||||
|
||||
/** What the host answered to a liveness report. */
|
||||
export interface RunningAccepted {
|
||||
/** How many reported titles matched an entry this provider currently publishes. */
|
||||
readonly matched: number;
|
||||
/** How many were ignored because no such entry exists (a report that raced a reconcile). */
|
||||
readonly unknown: number;
|
||||
/** Seconds the report stays authoritative without being restated. */
|
||||
readonly ttlS: number;
|
||||
}
|
||||
|
||||
export class ProviderClient extends Context.Service<
|
||||
ProviderClient,
|
||||
ProviderClientService
|
||||
@@ -72,6 +133,27 @@ export class ProviderClient extends Context.Service<
|
||||
: [],
|
||||
),
|
||||
),
|
||||
reportRunning: (providerId, running) =>
|
||||
host
|
||||
.request("PUT", `/library/provider/${providerId}/running`, {
|
||||
running,
|
||||
})
|
||||
.pipe(
|
||||
// Same posture as the reconcile echo above: the counts are a
|
||||
// diagnostic, not a contract, so a host that answers something
|
||||
// unexpected must not fail a plugin's report loop. The TTL falls
|
||||
// back to the host's own documented default.
|
||||
Effect.map((body) => {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
const num = (v: unknown, fallback: number) =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||
return {
|
||||
matched: num(b.matched, 0),
|
||||
unknown: num(b.unknown, 0),
|
||||
ttlS: num(b.ttl_s, DEFAULT_RUNNING_TTL_S),
|
||||
} satisfies RunningAccepted;
|
||||
}),
|
||||
),
|
||||
remove: (providerId) =>
|
||||
host
|
||||
.request("DELETE", `/library/provider/${providerId}`)
|
||||
|
||||
@@ -180,6 +180,7 @@ PUNKTFUNK_MSG_CLOCK_ECHO
|
||||
PUNKTFUNK_MSG_CLOCK_PROBE
|
||||
PUNKTFUNK_MSG_CURSOR_RENDER
|
||||
PUNKTFUNK_MSG_CURSOR_SHAPE
|
||||
PUNKTFUNK_MSG_DELIVERY_REPORT
|
||||
PUNKTFUNK_MSG_LOSS_REPORT
|
||||
PUNKTFUNK_MSG_PAIR_CHALLENGE
|
||||
PUNKTFUNK_MSG_PAIR_PROOF
|
||||
|
||||
Reference in New Issue
Block a user