12 Commits

Author SHA1 Message Date
enricobuehler 324da666e5 chore(release): bump workspace version to 0.8.2
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
web-screenshots / screenshots (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
linux-client-screenshots / screenshots (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
android-screenshots / screenshots (push) Has been cancelled
Release 0.8.2: native AMF encoder reconnect reliability on Windows AMD — a
second connection no longer comes up black (the encoder now Flushes before
Terminate so a reconnect's overlapping teardown can't strand AMD's VCN encode
session, self-heals with a full context rebuild when a reconnect still wedges,
and logs a per-context bring-up number + first-AU line so a silent wedge is
visible); a native data-plane hardening pass that keeps the stream alive across
real Wi-Fi links; Android streaming wake/Wi-Fi locks that actually engage plus a
console-UI polish pass (per-controller glyphs, scrollable dialogs, animated
forms); and gamescope-takeover survival on Bazzite's SDDM session supervisor.
Also a Linux-client crash fix — the FlowBox activation cycle that stack-
overflowed on every host-card click.

The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 9 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag (scripts/ci/pf-version.sh), so
cutting v0.8.2 auto-advances canary to 0.9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:16:19 +02:00
enricobuehler efed8d5a20 fix(host): AMF encoder reconnect reliability on Windows AMD
A second connection to a Windows AMD host came up black with nothing in the
logs. The native AMF encoder's teardown never Flushed before Terminate, so a
reconnect whose teardown overlapped the new session (a client may not signal an
explicit exit, so session 1 tears down late — on the reconnect preempt grace or
the QUIC idle timeout) left AMD's limited VCN encode-session slot occupied. The
new session's Init then opened onto a wedged session that returns AMF_OK but
never emits an AU. NVENC has no equivalent per-session cap, so NVIDIA never
showed it. Recovery couldn't help either: the stall watchdog re-Init'd the SAME
context, which can't clear a context/VCN-level fault, so it looped a dead
context until MAX_ENCODER_RESETS ended the session.

Reliability:
- Component::drop now Flushes before Terminate (mirrors reset() and the design
  doc), releasing the VCN session cleanly so the next session's Init gets a free
  slot.
- reset() escalates to a FULL context teardown once an in-place re-Init has run
  without producing an AU (resets_without_output >= 2), so a wedged reconnect
  self-heals via a fresh CreateContext+InitDX11 within the reset budget instead
  of re-initing a dead context in a loop.

Logging (the failure was silent):
- Per-context bring-up sequence number (context #N) — distinguishes a first
  connection from a reconnect's fresh context.
- A one-shot "AMF produced its first AU on this context" line; its absence after
  a context #N bring-up is the smoking gun for a silent VCN wedge.
- Terminate result logged on drop for both the component and the context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:09:59 +02:00
enricobuehler 636b9c1d1f Merge remote-tracking branch 'origin/main' 2026-07-07 06:15:21 +00:00
enricobuehler 92f078adaf fix(host): survive Bazzite's SDDM session supervisor in the gamescope takeover
Diagnosed live on the .181 Bazzite F44 box (couldn't connect at all; field
reports of streams dying after 30 s-5 min):

Bazzite autologs into game mode via SDDM with Relogin=true, so the moment the
managed takeover stops gamescope-session-plus@<client>, SDDM logs back in and
restarts it within the same second. The resurrected autologin session then
fights our transient session-plus over the Steam single instance and the GPU
for the whole stream: its wrapper relaunches gamescope every ~7 s (each one
missing the wrapper's hard 5 s readiness window on a slow NVIDIA init), the
churn SIGSEGVs gamescopes, and eventually the streaming gamescope dies with it.
Meanwhile a client that gave up left the pipeline-rebuild retry loop SIGKILLing
and relaunching the box's Steam session for up to ~6 more minutes.

- stop_autologin_sessions: runtime-mask each autologin unit before the SIGKILL
  stop, so no supervisor can restart it underneath the stream; match every
  loaded instance (the unit flaps through activating/failed mid-churn). Every
  restore path unmasks unconditionally (including the desktop-active early
  return), and --runtime keeps the mask in tmpfs so a reboot clears it.
- launch_session: supervise the transient unit while polling for the node —
  the session-plus wrapper kill -9s a gamescope that missed its 5 s readiness
  handshake and exits 1, so relaunch it (after a short driver-settle cooldown)
  instead of waiting the rest of the 45 s on a corpse.
- build_pipeline_with_retry: abort between attempts once the session's QUIC
  connection is closed — no more minutes of Steam churn for a departed client.

Validated live on .181: cold-boot connect streams 2059 frames/45 s (p50
5.1 ms), zero SDDM resurrections while masked, TV session restored+unmasked on
disconnect, warm same-mode reconnect reuses the session (866 frames/15 s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:15:12 +00:00
enricobuehler 17685ff73b build(android): debug APKs ship release-profile rust
Cargo's debug profile is not "slower" for this library — it is unusable, and
it invalidated every on-device performance test to date: RustCrypto's AES-GCM
compiles to generic-array iterator closures with per-byte precondition checks
instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf, 62k
samples): ~800 µs of user CPU per 1.4 KB packet — the receive pump pinned
above a full core yet only draining ~1,400 pkt/s of a 1,775 pkt/s (20 Mbps)
stream, 2.3 MB standing in the kernel socket buffer, the latency-bound flush
firing every 2 s forever. With release rust in the same debug APK: pump at
~12 % of a core, socket queue zero, no flushes, 2800x1260@120 streaming clean.

preDebugBuild now depends on cargoNdkRelease; `-PrustDebug` opts back into a
debug-profile native build for sessions that actually step through Rust.
Kotlin debuggability is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:07:42 +02:00
enricobuehler 08ab2b6bee fix(android): declare WAKE_LOCK — the stream's Wi-Fi locks never actually engaged
WifiLock.acquire() enforces the WAKE_LOCK permission, which the manifest never
declared — every acquisition since the first Wi-Fi lock shipped threw
SecurityException, silently swallowed by a bare runCatching. The phone's own
accounting proved it (dumpsys wifi: high_perf/low_latency active_time_ms = 0
across weeks of streams): every on-device session ran with Wi-Fi power save
fully active, whatever the code intended. Verified live after the fix: both
locks registered in WifiLockManager, mPowerSaveDisableRequests=2, ping RTT to
the streaming phone 3.8 ms avg. A failed acquire now logs loudly — this class
of failure must never be invisible again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:07:42 +02:00
enricobuehler b271d0c816 fix(android): hold BOTH Wi-Fi locks while streaming — HIGH_PERF alone is a no-op
The baseline stream held only WIFI_MODE_FULL_HIGH_PERF, which is deprecated
AND non-functional on recent Android — so with the low-latency toggle off (the
default) Wi-Fi power save stayed fully active: downlink delivery clumped at
beacon intervals (a few hundred ms of latency mush, sawtoothing bitrate) and
the AP's power-save buffer periodically overflowed, killing whole frames every
few seconds (the host log's alternating loss_ppm=0/50000). Now every stream
holds FULL_LOW_LATENCY (API 29+, the only effective power-save disable;
foreground + screen-on, which a stream always is) AND FULL_HIGH_PERF (covers
older releases) — the same pair Moonlight holds. The experimental toggle no
longer selects the lock mode.

Also: declare tracing's "log" feature explicitly in the native crate (core
transport warnings → logcat must not hinge on quinn's default features), and
align the low-latency toggle's copy with its actual scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler 83b7c7adf5 feat(android): console UI — per-controller glyphs, dialog scrolling, animated forms
- Hint-bar glyphs now wear the driving controller's family (kit
  Gamepad.styleFor by USB vendor id → MainActivity.lastPadStyle, kept live by
  real input like lastPadIsGamepad): PlayStation pads get Canvas-drawn
  cross/circle/square/triangle shapes in the classic colours, Nintendo pads
  monochrome lettering, Xbox/Valve/unknown the coloured letter discs. Hint
  chars stay semantic (KEYCODE_BUTTON names); only the rendering changes.
- The Options legend renders the pad's real Select-family button
  (SelectButtonGlyph): Xbox View windows, PlayStation Create capsule,
  Nintendo minus — instead of a bare capsule outline.
- GamepadDialog: body + action stack scroll together (title pinned) with
  BringIntoViewRequester keeping the focused button visible — a 5-action host
  options dialog compressed/clipped its last button in short landscape
  windows because the pinned stack could not scroll.
- Console form polish: shared animateConsoleFocus (bg/border cross-fade +
  spring scale) across settings rows / add-host fields / action rows;
  ConsoleSwitch (spring knob, tinting track) replaces On/Off text on toggle
  rows; choice values slide in the direction they were stepped
  (AnimatedContent + SizeTransform) with chevrons that fade in place; the
  focused row's detail unfolds via AnimatedVisibility; dialog buttons and
  keyboard keycaps cross-fade (keycaps at 90 ms for hold-to-repeat).
- Console settings gain the "Low-latency mode" (Video) and "Auto-wake on
  connect" (Interface) rows, round-tripping with the touch settings.
- Screenshot scene: StatsOverlay call updated to the 18-double layout + the
  new decoderLabel parameter (fixes the android-screenshots CI compile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler eea23c5647 fix(core,host): make the native data plane survive real Wi-Fi links
Root-caused live on a phone at 100 Mbps (stream stuck seconds behind, then
oscillating): a stack of transport defects, each amplifying the next.

- MTU-safe shards: shard_payload 1452 overshot the IPv4/1500 budget (the old
  math forgot the 40 B header + 24 B crypto ride inside the UDP payload and
  counted IP+UDP as 8 B) — the kernel silently split EVERY video datagram into
  two IP fragments, doubling per-datagram loss on Wi-Fi. New
  config::mtu1500_shard_payload() = 1408 (1472 sealed = the exact ceiling),
  negotiated in the Welcome, pinned by a unit test.

- Android batched I/O: recv/send batching was cfg(linux); Android is
  target_os="android" and silently fell back to a syscall per datagram. The
  libc crate binds neither recvmmsg/sendmmsg nor mmsghdr for Android, so a
  local bionic extern binding provides them (API 21+, floor is 28); cbindgen
  excludes them from the C header. The pump/runtime threads also get the
  Apple-QoS analogue on Android: nice −8 (below the decode thread's −10).

- Latency-bounded receive: packets are consumed strictly in order at exactly
  the arrival rate, so a standing queue (Wi-Fi stall, power-save clumping)
  NEVER drains — observed as a stream permanently 6-7 s behind with both 32 MB
  socket buffers full. The pump now flushes the entire backlog
  (Session::flush_backlog: discard ring + kernel queue at memcpy speed, reset
  the reassembler) and requests a keyframe when frames keep completing > 400 ms
  behind the skew-corrected capture clock (30 consecutive, 2 s cooldown,
  logged).

- Time-based loss window: the reassembler declared an incomplete frame lost a
  fixed 4 INDICES behind the newest — 33 ms at 120 fps, inside normal Wi-Fi
  retry/reorder timescales, so merely-late frames were pruned every few
  seconds, each costing a recovery-IDR burst + an inflated loss report.
  Now 120 ms of capture time (LOSS_WINDOW_NS), same fuse at every refresh
  rate, with a 64-index hard cap bounding memory against hostile pts.

- Adaptive-FEC hysteresis: the controller was memoryless — one clean 750 ms
  report dropped FEC from 8 % straight back to the 1 % floor, so periodic burst
  loss (Wi-Fi scan / BT coexistence beats) always hit an unprotected stream and
  ping-ponged 1↔8 % with a frozen frame per cycle (observed in the host log as
  alternating loss_ppm=0/50000). Attack stays instant; decay is now one point
  per clean report.

Verified: full core suite (incl. new flush + time-window tests) on macOS +
Linux, host release build, arm64 cargo-ndk build, and a 30 s wired probe run
at 2800x1260@120 — 3559/3559 frames, zero loss, capture→received p50 5.3 ms
(host 5.1 + network 0.3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler 912d7de2e6 style(linux): rustfmt drift from the last two commits
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:22:46 +02:00
enricobuehler e788d0de84 feat(client-linux): log the release/disconnect keyboard chords
The Ctrl+Alt+Shift+Q/D handlers had no tracing, so a report of "the
disconnect shortcut doesn't work" was unverifiable from logs alone —
live tracing (added temporarily, then trimmed to these two lines)
showed the chord, `disconnect_quit()`, and the session teardown all
firing correctly and instantly every time; the confusion traced back
to the (now-fixed) FlowBox click crash having kept everyone from ever
reaching a live session to test the shortcut with in the first place.

Keep the two low-noise, deliberate-action log lines for the next time
this comes up; drop the per-keystroke debug trace used to diagnose it,
which would otherwise fire on every key during a stream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:21:26 +02:00
enricobuehler 53c8eefa99 fix(client-linux): break the FlowBox activation signal cycle — stack overflow on every host-card click
`child-activated` (fired by a pointer click) was bridged to `child.activate()`
so each card's own connect handler (wired on the child's `activate` signal)
would run. But `child.activate()` runs `GtkFlowBoxChild`'s default handler,
which re-emits `child-activated` on the FlowBox — bouncing straight back into
the same closure. Unguarded, that ping-pong recursed forever, overflowing the
stack on every single host-card click or Enter-key activation (confirmed live
via coredump/gdb: 43k+ stack frames of gobject signal emission, and the
`fatal runtime error: stack overflow, aborting` in the crash log).

A re-entrancy flag breaks the cycle after the one real activation. Added a
regression test that wires the identical FlowBox/FlowBoxChild signal cycle
against a real display and asserts it returns instead of recursing — it
reproduces the exact stack overflow against the old code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 23:59:04 +02:00
30 changed files with 1237 additions and 227 deletions
Generated
+10 -9
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.8.1"
version = "0.8.2"
[[package]]
name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"punktfunk-core",
]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"android_logger",
"jni",
@@ -2918,11 +2918,12 @@ dependencies = [
"ndk",
"opus",
"punktfunk-core",
"tracing",
]
[[package]]
name = "punktfunk-client-linux"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"async-channel",
@@ -2945,7 +2946,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"async-channel",
@@ -2968,7 +2969,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"aes-gcm",
"bytes",
@@ -2999,7 +3000,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"aes",
"aes-gcm",
@@ -3071,7 +3072,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3085,7 +3086,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package]
version = "0.8.1"
version = "0.8.2"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.8.1"
"version": "0.8.2"
},
"paths": {
"/api/v1/clients": {
@@ -13,6 +13,12 @@
reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
@@ -2,7 +2,8 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
color = labelColor,
)
}
}
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (focused) Color.Black else Color.White,
color = fg,
textAlign = TextAlign.Center,
)
}
@@ -1,10 +1,14 @@
package io.unom.punktfunk
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
}
/**
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
* controller can still drive the console UI (and reach Settings to switch it off).
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
* drive the console UI (and reach Settings to switch it off).
*/
class GamepadHint(
val glyph: Char,
@@ -201,11 +217,16 @@ class GamepadHint(
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y.
val select: Boolean = false,
// Render as the gamepad Select/View button (a small capsule).
// Render as the pad's physical Select/View/Create/ button (per PadStyle) — the button that
// delivers KEYCODE_BUTTON_SELECT.
val viewButton: Boolean = false,
)
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
/**
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
*/
object PadGlyph {
val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B)
@@ -216,6 +237,87 @@ object PadGlyph {
)
}
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
internal val PadButtonFace = Color(0xFF2A2740)
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
/**
* The focus visuals every console form element shares (settings rows, add-host fields, action
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
* of a field actively receiving keyboard input.
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
)
return ConsoleFocusVisuals(scale, background, border)
}
/**
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
* track that tints as it engages while the knob slides across on a spring — the state change reads
* from across the room, and the motion confirms the press.
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
val trackW = 44.dp
val trackH = 24.dp
val pad = 3.dp
val knob = trackH - pad * 2
Box(
modifier
.size(trackW, trackH)
.clip(RoundedCornerShape(50))
.background(track)
.border(1.dp, outline, RoundedCornerShape(50)),
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = pad)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
)
}
}
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size)
}
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
/**
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
* DualShock colours.
*/
@Composable
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
val color = when (glyph) {
'A' -> Color(0xFF7C9CE8) // cross — light blue
'B' -> Color(0xFFE0736F) // circle — red
'X' -> Color(0xFFD48FC7) // square — pink
else -> Color(0xFF5FBFA5) // triangle — green
}
Box(
Modifier
.size(width = size * 0.74f, height = size * 0.46f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(size * 0.46f)) {
val w = this.size.minDimension
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
when (glyph) {
'A' -> { // ✕ — the two diagonals
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
}
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
'X' -> drawRect(
color,
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
size = Size(w - stroke.width, w - stroke.width),
style = stroke,
)
else -> { // △
val p = Path().apply {
moveTo(w / 2f, stroke.width / 2f)
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
lineTo(stroke.width / 2f, w - stroke.width / 2f)
close()
}
drawPath(p, color, style = stroke)
}
}
}
}
}
/**
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo (minus). The generic
* fallback wears the capsule too (the near-universal select shape).
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
when (style) {
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
// The View icon: two overlapping outlined windows; the front one is filled with the
// button face so it visibly occludes the back one.
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
)
else -> Box(
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
)
}
}
}
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
// Defaults to the generic gamepad look off an Activity (preview/tests).
val activity = LocalContext.current as? MainActivity
val padIsGamepad = activity?.lastPadIsGamepad ?: true
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
}
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when {
h.viewButton -> ViewButtonGlyph()
h.viewButton -> SelectButtonGlyph(padStyle)
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
PsFaceGlyph(h.glyph)
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
GamepadButtonGlyph(h.glyph, PadButtonFace)
else -> GamepadButtonGlyph(h.glyph, h.color)
}
Spacer(Modifier.width(6.dp))
@@ -2,7 +2,12 @@ package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -90,8 +98,11 @@ fun GamepadDialog(
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
@@ -109,43 +120,66 @@ fun GamepadDialog(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
// The body scrolls; the title above and the buttons below stay pinned + always visible.
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
}
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
val bg = when {
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
}
val fg = when {
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
}
},
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
.border(1.dp, borderColor, shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
@@ -2,7 +2,19 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -57,6 +69,7 @@ private class GpRow(
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
)
@Composable
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
}
},
onActivate = { rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, onClick = {
if (focus == index) row.activate() else focus = index
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
})
}
}
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
Column {
if (row.header != null) {
Text(
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
maxLines = 1,
)
Spacer(Modifier.weight(1f))
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
if (row.toggled != null) {
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
Text(
row.value,
value,
style = MaterialTheme.typography.bodyMedium,
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It appears right where you're looking, and the row grows to fit.
if (focused && row.detail.isNotBlank()) {
// detail strip. It unfolds right where you're looking, and the row grows to fit.
AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
detail = detail,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
return listOf(
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", null, "Low-latency mode",
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
@@ -304,6 +356,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
@@ -59,12 +59,22 @@ class MainActivity : ComponentActivity() {
var lastPadIsGamepad by mutableStateOf(true)
private set
/**
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
*/
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode()
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
@@ -159,9 +169,11 @@ class MainActivity : ComponentActivity() {
}
} else {
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -217,6 +229,7 @@ class MainActivity : ComponentActivity() {
lastNavDir = dir
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -55,12 +55,12 @@ data class Settings(
*/
val libraryEnabled: Boolean = true,
/**
* "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency
* lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul
* pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some
* phones, so it's opt-in until it's proven per-device.
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
* known-good baseline until the aggressive stack is proven per-device.
*/
val lowLatencyMode: Boolean = false,
/**
@@ -328,7 +328,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
ToggleRow(
title = "Low-latency mode (experimental)",
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
"decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " +
"decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
"some devices — turn off if the stream misbehaves.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
@@ -65,9 +66,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
// default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack —
// decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
// HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
// DSCP marking (applied earlier, at connect).
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
@@ -117,26 +118,40 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
// Part of the experimental low-latency stack — not created at all when the toggle is off.
val wifiLock = remember(handle) {
if (!lowLatencyMode) return@remember null
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
// Android; it needs the app foreground + screen on, which a stream always is.
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
// HIGH_PERF left power save fully active on Android 13+).
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
// explicit acquire/release each.
val wifiLocks = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION")
WifiManager.WIFI_MODE_FULL_HIGH_PERF
?: return@remember emptyList<WifiManager.WifiLock>()
buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
?.let(::add)
}
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
@Suppress("DEPRECATION")
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
?.let(::add)
}.onEach { it.setReferenceCounted(false) }
}
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
runCatching { wifiLock?.acquire() }
wifiLocks.forEach { lock ->
runCatching { lock.acquire() }.onFailure { e ->
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
}
}
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
// 30+. Part of the experimental low-latency stack.
@@ -175,7 +190,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
// Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -187,12 +187,19 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
// split equation, and the decoder label line shows the ranked low-latency decoder.
StatsOverlay(
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
Modifier.align(Alignment.TopStart).padding(12.dp),
doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
),
decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
}
+11 -1
View File
@@ -110,8 +110,18 @@ afterEvaluate {
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
// for every normal APK/AAR build.
//
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) {
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
}
}
@@ -57,6 +57,7 @@ object Gamepad {
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
@@ -98,6 +99,28 @@ object Gamepad {
}
}
/**
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
*/
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
/**
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
* pad the /+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
* Xbox convention.
*/
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
VID_SONY -> PadStyle.PLAYSTATION
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
VID_NINTENDO -> PadStyle.NINTENDO
else -> PadStyle.GENERIC
}
/** True when [dev]'s source classes include gamepad or joystick. */
fun isPad(dev: InputDevice?): Boolean {
val s = dev?.sources ?: return false
+8
View File
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
# set, so declare it explicitly.
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
+4 -1
View File
@@ -44,7 +44,10 @@ mod stats;
mod wol;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
+62 -1
View File
@@ -162,9 +162,20 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
// the child's own `activate` signal — so bridge it back to the child, where each card wires
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
//
// `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits
// `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded,
// that ping-pong recurses forever and overflows the stack on every single card click/Enter
// (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after
// the one real activation.
for flow in [&saved_flow, &disc_flow] {
flow.connect_child_activated(|_, child| {
let activating = std::cell::Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
}
@@ -720,3 +731,53 @@ fn add_host_dialog(state: &Rc<State>) {
}
dialog.present(Some(&state.stack));
}
#[cfg(test)]
mod tests {
use adw::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
// Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges
// to `child.activate()`, whose own default handler re-emits `child-activated` on the
// FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter)
// until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a
// simulation of it, so it fails the same way the shipped bug did if the guard regresses.
#[test]
#[ignore = "needs a Wayland/X display"]
fn flow_box_activation_bridge_does_not_recurse() {
assert!(gtk::init().is_ok(), "no display");
let flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.build();
let activating = Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
let child = gtk::FlowBoxChild::new();
flow.insert(&child, -1);
let fired = Rc::new(Cell::new(0u32));
{
let fired = fired.clone();
child.connect_activate(move |_| fired.set(fired.get() + 1));
}
// What a pointer click with `activate_on_single_click` does internally: emit
// `child-activated` directly on the FlowBox. A regression here overflows the stack
// instead of returning.
flow.emit_by_name::<()>("child-activated", &[&child]);
assert_eq!(
fired.get(),
1,
"the per-card handler should fire exactly once"
);
}
}
+5
View File
@@ -806,6 +806,10 @@ fn attach_keyboard(
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
tracing::info!(
captured = cap.captured.get(),
"chord: Ctrl+Alt+Shift+Q (release/engage)"
);
if cap.captured.get() {
cap.release();
} else {
@@ -816,6 +820,7 @@ fn attach_keyboard(
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting");
cap.release();
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down
// immediately instead of holding the keep-alive linger for a reconnect.
+1 -1
View File
@@ -16,7 +16,7 @@ use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair;
const TAG_LEN: usize = 16; // AES-GCM authentication tag
const SHARD: usize = 1452; // ~one MTU-sized data shard
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
fn cfg(role: Role, scheme: FecScheme) -> Config {
Config {
+5 -4
View File
@@ -13,10 +13,11 @@ documentation_style = "c99"
parse_deps = false
[export]
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
exclude = ["MsghdrX", "recvmsg_x"]
# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
[export.rename]
"InputEvent" = "PunktfunkInputEvent"
+81 -3
View File
@@ -123,6 +123,24 @@ pub struct ProbeOutcome {
/// (display freshness over completeness — FEC/keyframes recover).
const FRAME_QUEUE: usize = 16;
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
/// capture clock (skew-corrected), the pump flushes the receive backlog
/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind
/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the
/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery
/// jitter so a healthy stream can never trip it.
const FLUSH_LATENCY: Duration = Duration::from_millis(400);
/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue
/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a
/// frame or two and never reaches the count.
const FLUSH_AFTER_FRAMES: u32 = 30;
/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a
/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning
/// instead of a continuous flush/keyframe storm.
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
/// embedder drops the newest packet (the audio renderer conceals the gap).
const AUDIO_QUEUE: usize = 64;
@@ -248,8 +266,9 @@ pub struct NativeClient {
/// std channels these worker threads feed; if the producers run at the default QoS, the
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the
/// android arm below); a no-op elsewhere (the Linux client/host don't run a QoS scheduler, and
/// `punktfunk-probe` doesn't care).
#[cfg(target_vendor = "apple")]
fn pin_thread_user_interactive() {
// SAFETY: sets only the current thread's QoS class — always valid to call.
@@ -257,9 +276,33 @@ fn pin_thread_user_interactive() {
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
}
}
#[cfg(not(target_vendor = "apple"))]
/// Android analogue of the Apple QoS pin: raise the calling thread to nice 8 (the framework's
/// URGENT_DISPLAY band — apps may set negative nice on their own threads). At default nice 0 the
/// EAS scheduler happily parks the data-plane pump (UDP receive + decrypt + FEC — a thread that
/// sleeps between bursts) on a down-clocked little core, and a few ms of scheduling delay during a
/// keyframe burst overflows the socket receive buffer → wire loss the link never saw. 8 keeps the
/// pipeline below the decode thread's 10 (the display path still wins). Best-effort, like Apple's.
#[cfg(target_os = "android")]
fn pin_thread_user_interactive() {
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; a refusal is
// reported via the return value (ignored — a missed boost, not an error on the data path).
unsafe {
let tid = libc::gettid();
let _ = libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8);
}
}
#[cfg(not(any(target_vendor = "apple", target_os = "android")))]
fn pin_thread_user_interactive() {}
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
/// capture `pts_ns` after the skew offset is applied — the same latency math the stats HUDs use.
fn now_realtime_ns() -> i128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
/// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
/// there's nothing to hint with, so registration is a no-op.
@@ -1196,6 +1239,11 @@ async fn worker_main(args: WorkerArgs) {
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
// Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last
// flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) —
// without it the host and client clocks aren't comparable and the bound would misfire.
let mut stale_frames: u32 = 0;
let mut last_flush: Option<Instant> = None;
while !pump_shutdown.load(Ordering::SeqCst) {
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput
@@ -1230,6 +1278,36 @@ async fn worker_main(args: WorkerArgs) {
if frame.flags & FLAG_PROBE as u32 != 0 {
continue; // speed-test filler, not video — measured via the counters above
}
// Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi
// stall, power-save clumping) never drains by itself — the pump consumes at
// exactly the arrival rate, so once behind, the stream stays behind for good
// (observed live: stuck 67 s). When frames keep completing over the bound,
// discard the whole backlog and ask for a keyframe: one visible skip instead of
// a permanently unusable stream. Suspended during a speed test (the probe
// MEASURES a saturated queue; flushing would corrupt its receive counters).
if clock_offset_ns != 0 && !probe_active {
let lat_ns =
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128;
if lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
stale_frames += 1;
} else {
stale_frames = 0;
}
if stale_frames >= FLUSH_AFTER_FRAMES
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
{
stale_frames = 0;
last_flush = Some(Instant::now());
let flushed = session.flush_backlog().unwrap_or(0);
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
tracing::warn!(
behind_ms = lat_ns / 1_000_000,
flushed_datagrams = flushed,
"receive backlog exceeded the latency bound — flushed to live"
);
continue; // this frame is part of the stale past — don't render it
}
}
let _ = frame_tx.try_send(frame);
}
Err(PunktfunkError::NoFrame) => {
+26
View File
@@ -256,6 +256,19 @@ pub const fn max_shard_payload() -> usize {
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
}
/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP
/// packet on a standard 1500-byte MTU: `1500 20 (IPv4) 8 (UDP) HEADER_LEN CRYPTO_OVERHEAD`
/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently
/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment
/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into
/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly
/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride
/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.)
pub const fn mtu1500_shard_payload() -> usize {
let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
p - p % 2 // FEC requires even shards
}
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
@@ -392,6 +405,19 @@ mod tests {
assert!(c.validate().is_err());
}
/// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe
/// shard payload must be ≤ 1472 (1500 IPv4 20 UDP 8), and one shard-step (+2) above must
/// not — the regression that shipped as 1452 and IP-fragmented every video datagram.
#[test]
fn mtu1500_shard_payload_never_fragments() {
let p = mtu1500_shard_payload();
assert_eq!(p % 2, 0, "FEC requires even shards");
assert!(p <= max_shard_payload());
let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment");
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal");
}
#[test]
fn rejects_block_exceeding_scheme_ceiling() {
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
+148 -20
View File
@@ -43,8 +43,29 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
pub const MAX_DATAGRAM_BYTES: usize = 2048;
/// How many frames behind the newest the reassembler keeps before pruning stragglers.
const REORDER_WINDOW: u32 = 16;
/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is
/// declared lost (counted in `frames_dropped`, which triggers the client's recovery-keyframe
/// request). TIME-based, not frame-count-based, so the fuse is the same at every refresh rate: a
/// fixed index window is refresh-relative (4 frames = 66 ms at 60 fps but only 33 ms at 120 fps —
/// inside normal Wi-Fi retry/block-ack reorder timescales, where a delayed-not-lost shard can
/// trail newer frames). Observed live at 120 fps: the too-tight fuse declared merely-late frames
/// dead every few seconds, and each false loss cost a recovery-IDR burst + an inflated loss report
/// (FEC churn) — a self-sustaining latency/bitrate oscillation. 120 ms rides safely above radio
/// retry jitter while still detecting a real loss ~2× faster than the original 16-frame window did
/// at 60 fps.
const LOSS_WINDOW_NS: u64 = 120_000_000;
/// Hard cap on how many frame INDICES behind the newest an incomplete frame may sit, whatever its
/// pts claims — bounds the reassembler's memory against a corrupt/hostile pts (which
/// [`LOSS_WINDOW_NS`] alone would trust) and against pathologically high frame rates. At 120 fps,
/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps.
const HARD_LOSS_WINDOW: u32 = 64;
/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices
/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an
/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the
/// loss verdict.
const REORDER_WINDOW: u32 = 64;
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
#[repr(C)]
@@ -274,7 +295,10 @@ pub struct Reassembler {
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
/// the reorder window alongside `frames`.
completed: HashSet<u32>,
newest_frame: Option<u32>,
/// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
/// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
newest_frame: Option<(u32, u64)>,
}
impl Reassembler {
@@ -344,12 +368,12 @@ impl Reassembler {
}
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
self.advance_window(hdr.frame_index, stats);
self.advance_window(hdr.frame_index, hdr.pts_ns, stats);
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
// frame that completed early via the all-originals-present fast path) or that
// have fallen out of the reorder window.
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) {
// have fallen out of the loss window.
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
drop(stats);
return Ok(None);
}
@@ -461,19 +485,31 @@ impl Reassembler {
Ok(None)
}
/// Track the newest frame and prune stragglers that fell out of the reorder window
/// (counting them as dropped).
fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) {
let newest = match self.newest_frame {
/// Track the newest frame, declare incomplete frames that fell out of the loss window
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
/// them dropped, which is what drives the client's recovery-keyframe request — and prune the
/// completed-index memory to [`REORDER_WINDOW`].
fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) {
let (newest, newest_pts) = match self.newest_frame {
// `frame_index` is newer iff it's within the forward half of the index space.
Some(n) if frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
_ => frame_index,
Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
_ => (frame_index, pts_ns),
};
self.newest_frame = Some(newest);
self.newest_frame = Some((newest, newest_pts));
let before = self.frames.len();
self.frames
.retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
let completed = &mut self.completed;
self.frames.retain(|&idx, f| {
let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW
&& newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS;
if !keep {
// Remember the abandoned index so a straggler shard is dropped (below, and in
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
// and double-count the drop when it aged out again.
completed.insert(idx);
}
keep
});
let pruned = before - self.frames.len();
if pruned > 0 {
StatsCounters::add(&stats.frames_dropped, pruned as u64);
@@ -482,13 +518,29 @@ impl Reassembler {
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
}
/// True if `frame_index` lies behind the newest frame by more than the reorder
/// window (so its shards arrive too late to be useful).
fn is_stale(&self, frame_index: u32) -> bool {
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
/// index memory — as if the session just started. Used by the client's backlog flush
/// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket
/// backlog is discarded wholesale, the partial frames here can never complete (their remaining
/// shards were just thrown away) and the window anchor (`newest_frame`) points into the
/// discarded past.
pub fn reset(&mut self) {
self.frames.clear();
self.completed.clear();
self.newest_frame = None;
}
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
/// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards
/// arrive too late to be useful, and accepting one would only create a frame buffer the next
/// [`advance_window`] immediately declares lost.
fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool {
match self.newest_frame {
Some(n) => {
Some((n, newest_pts)) => {
let behind = n.wrapping_sub(frame_index);
behind > REORDER_WINDOW && behind <= u32::MAX / 2
behind <= u32::MAX / 2
&& (behind > HARD_LOSS_WINDOW
|| newest_pts.saturating_sub(pts_ns) > LOSS_WINDOW_NS)
}
None => false,
}
@@ -585,6 +637,82 @@ mod tests {
assert_eq!(stats.snapshot().packets_dropped, 1);
}
/// The loss window is TIME-based: an incomplete frame survives newer frames arriving within
/// [`LOSS_WINDOW_NS`] of its capture pts (a 33 ms-late shard at 120 fps is late, not lost —
/// the old 4-INDEX window wrongly killed it), is declared lost once the newest pts moves past
/// the window (`frames_dropped`), and a straggler shard can't resurrect it afterwards.
#[test]
fn incomplete_frames_age_out_by_capture_time_not_frame_count() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
const FRAME_NS: u64 = 8_333_333; // 120 fps
// Frame 0: one of its two shards arrives — incomplete.
let mut h = base_header();
h.data_shards = 2;
h.frame_bytes = 32;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Frames 1..=8 complete around it (well past the old 4-index window, inside 120 ms):
// frame 0 must still be alive — no drop counted.
for i in 1..=8u32 {
let mut h = base_header();
h.frame_index = i;
h.pts_ns = i as u64 * FRAME_NS;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
}
assert_eq!(stats.snapshot().frames_dropped, 0);
// Frame 0's second shard arrives 8 frames late (~66 ms at 120 fps) — completes fine.
let mut h = base_header();
h.data_shards = 2;
h.frame_bytes = 32;
h.shard_index = 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
// Frame 20: incomplete again; then a frame lands past the 120 ms window → declared lost.
let mut h = base_header();
h.frame_index = 20;
h.pts_ns = 20 * FRAME_NS;
h.data_shards = 2;
h.frame_bytes = 32;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
let mut h = base_header();
h.frame_index = 21;
h.pts_ns = 20 * FRAME_NS + LOSS_WINDOW_NS + 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
assert_eq!(stats.snapshot().frames_dropped, 1);
// A straggler shard for the abandoned frame 20 is dropped, never resurrected.
let mut h = base_header();
h.frame_index = 20;
h.pts_ns = 20 * FRAME_NS;
h.data_shards = 2;
h.frame_bytes = 32;
h.shard_index = 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count");
}
#[test]
fn rejects_wrong_shard_bytes_and_oversized_frame() {
let coder = coder_for(FecScheme::Gf8);
+39
View File
@@ -290,6 +290,45 @@ impl Session {
}
}
/// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
/// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
/// were thrown away (counted into `packets_dropped`).
///
/// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
/// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
/// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
/// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
/// (observed live: a stream stuck 67 s behind, socket buffers full end to end). Discarding
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
/// outpacing the discard loop indefinitely.
pub fn flush_backlog(&mut self) -> Result<u64> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"flush_backlog called on a host session",
));
}
// The undelivered tail of the current ring is backlog too.
let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
self.recv_count = 0;
self.recv_idx = 0;
if !self.recv_scratch.is_empty() {
for _ in 0..4096 {
let n = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
if n == 0 {
break;
}
flushed += n as u64;
}
}
self.reassembler.reset();
StatsCounters::add(&self.stats.packets_dropped, flushed);
Ok(flushed)
}
/// Client: serialize and send one input event to the host.
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
if self.config.role != Role::Client {
+53 -16
View File
@@ -1,10 +1,12 @@
//! Real UDP datagram transport — native sockets, no async runtime.
//!
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
//! fallbacks for loopback and the remaining targets).
use super::Transport;
use crate::packet::MAX_DATAGRAM_BYTES;
@@ -57,16 +59,51 @@ fn is_transient_io(e: &std::io::Error) -> bool {
}
}
/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall
/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic
/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is
/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to
/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in
/// `linux/socket.h`): a `msghdr` followed by the received byte count.
#[cfg(target_os = "android")]
mod android_mmsg {
#[repr(C)]
#[allow(non_camel_case_types)]
pub struct mmsghdr {
pub msg_hdr: libc::msghdr,
pub msg_len: libc::c_uint,
}
extern "C" {
pub fn sendmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
vlen: libc::c_uint,
flags: libc::c_int,
) -> libc::c_int;
pub fn recvmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
vlen: libc::c_uint,
flags: libc::c_int,
timeout: *mut libc::timespec,
) -> libc::c_int;
}
}
#[cfg(target_os = "android")]
use android_mmsg::{mmsghdr, recvmmsg, sendmmsg};
#[cfg(target_os = "linux")]
use libc::{mmsghdr, recvmmsg, sendmmsg};
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
///
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
#[cfg(target_os = "linux")]
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
#[cfg(any(target_os = "linux", target_os = "android"))]
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
iovs.iter_mut()
.map(|iov| {
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
let mut h: mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1;
h
@@ -575,9 +612,9 @@ impl Transport for UdpTransport {
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to
/// the trait's scalar `send` loop (no `sendmmsg`).
#[cfg(target_os = "linux")]
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
/// to the trait's scalar `send` loop (no `sendmmsg`).
#[cfg(any(target_os = "linux", target_os = "android"))]
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
const CHUNK: usize = 64;
@@ -593,7 +630,7 @@ impl Transport for UdpTransport {
})
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
let n = unsafe { sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
if n < 0 {
let err = std::io::Error::last_os_error();
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
@@ -723,9 +760,9 @@ impl Transport for UdpTransport {
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
/// trait's scalar default.
#[cfg(target_os = "linux")]
/// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
/// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
@@ -743,7 +780,7 @@ impl Transport for UdpTransport {
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe {
libc::recvmmsg(
recvmmsg(
fd,
hdrs.as_mut_ptr(),
n_bufs as libc::c_uint,
@@ -772,7 +809,7 @@ impl Transport for UdpTransport {
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
#[cfg(all(unix, not(target_os = "linux")))]
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
// and falls through to the always-correct scalar loop below.
+42
View File
@@ -112,6 +112,48 @@ fn lossless_stream_is_exact() {
);
}
/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram
/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past
/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact.
#[test]
fn flush_backlog_discards_queue_and_recovers() {
let (host_tp, client_tp) = loopback_pair(0, 0);
let mut host = Session::new(
config(Role::Host, FecScheme::Gf16, false, 0),
Box::new(host_tp),
)
.unwrap();
let mut client = Session::new(
config(Role::Client, FecScheme::Gf16, false, 0),
Box::new(client_tp),
)
.unwrap();
let frames = sample_frames();
// Read one frame first so the client's recv ring exists and may hold an undelivered tail.
host.submit_frame(&frames[0], 0, 0).unwrap();
client.poll_frame().unwrap();
// Queue a multi-frame backlog, then flush it: everything pending is discarded.
for (i, f) in frames.iter().enumerate().skip(1) {
host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap();
}
let flushed = client.flush_backlog().unwrap();
assert!(flushed > 0, "a queued backlog must be discarded");
assert_eq!(client.stats().packets_dropped, flushed);
assert!(
matches!(
client.poll_frame(),
Err(punktfunk_core::PunktfunkError::NoFrame)
),
"nothing pending after a flush"
);
// The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact.
let recovery = vec![0xA5u8; 100_000];
host.submit_frame(&recovery, 99_000_000, 0).unwrap();
let got = client.poll_frame().expect("post-flush frame completes");
assert_eq!(got.data, recovery);
}
#[test]
fn input_round_trips_client_to_host() {
let (host_tp, client_tp) = loopback_pair(0, 0);
+128 -17
View File
@@ -807,11 +807,27 @@ struct Component(*mut sys::AmfComponent);
impl Drop for Component {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null component `CreateComponent` returned with its own
// reference, owned exclusively by this guard; both calls go through its runtime-provided
// vtable on the owning thread. Terminate-then-Release is the documented teardown order,
// and this drop runs exactly once.
// reference, owned exclusively by this guard; every call goes through its runtime-provided
// vtable on the owning thread. Flush-then-Terminate-then-Release is the teardown order
// `reset()` and design/native-amf-encoder.md §"reset natively" use, and this drop runs
// exactly once.
unsafe {
((*(*self.0).vtbl).terminate)(self.0);
// Flush BEFORE Terminate so the VCN hardware encode session is released cleanly. An
// un-flushed Terminate (surfaces still in flight) can leave AMD's limited VCN
// session slots occupied for a beat, and the NEXT session's `Init` — a reconnect
// whose teardown overlaps ours, since a client may not signal an explicit exit — then
// opens onto a busy/wedged session that returns AMF_OK but never emits an AU. That is
// the "second connection silently dead on AMD" symptom; NVENC has no equivalent
// per-session cap, so it never shows. Results are best-effort (a wedged component is
// legal to flush/terminate), logged for the teardown trace.
((*(*self.0).vtbl).flush)(self.0);
let tr = ((*(*self.0).vtbl).terminate)(self.0);
if tr != sys::AMF_OK {
tracing::debug!(
result = %format!("{} ({tr})", result_name(tr)),
"AMF component Terminate returned non-OK on drop"
);
}
((*(*self.0).vtbl).release)(self.0);
}
}
@@ -826,7 +842,13 @@ impl Drop for Ctx {
// first — `Inner` declares `comp` before `ctx`). Terminate releases the D3D11 device
// binding; Release drops the last reference. Runs exactly once on the owning thread.
unsafe {
((*(*self.0).vtbl).terminate)(self.0);
let tr = ((*(*self.0).vtbl).terminate)(self.0);
if tr != sys::AMF_OK {
tracing::debug!(
result = %format!("{} ({tr})", result_name(tr)),
"AMF context Terminate returned non-OK on drop (D3D11 device unbind)"
);
}
((*(*self.0).vtbl).release)(self.0);
}
}
@@ -890,8 +912,15 @@ unsafe fn set_prop(
/// depth-1/2 steady state only 1-2 slots are ever live.
const RING: usize = 6;
/// Process-wide count of AMF encoder contexts brought up (`ensure_inner` bumps it on a successful
/// `Init`). Logged per bring-up so the trace distinguishes a first connection (`context #1`) from a
/// reconnect's fresh context (`context #2`, `#3`, …) — the axis the "second connection silently
/// dead on AMD" report lives on. A reconnect whose context number climbs but whose "first AU"
/// line (see [`Inner::note_first_au`]) never follows is a silent VCN-session wedge.
static AMF_CONTEXTS_OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The live AMF session: context + encoder component on the capturer's device, plus the owned
/// input texture ring. Field order matters: `comp` drops (Terminate+Release) before `ctx`.
/// input texture ring. Field order matters: `comp` drops (Flush+Terminate+Release) before `ctx`.
struct Inner {
comp: Component,
ctx: Ctx,
@@ -913,6 +942,26 @@ struct Inner {
/// The HDR mastering metadata last pushed to THIS component (`*InHDRMetadata`), so `submit`
/// re-pushes only on change — and a rebuilt component starts clean and gets it again.
hdr_pushed: Option<punktfunk_core::quic::HdrMeta>,
/// Whether this context has emitted its first AU yet — gates a single info log confirming the
/// encoder actually produces output. Its ABSENCE after a `context #N created` line is the
/// smoking gun for a silently-wedged reconnect (Init succeeded, VCN never encodes).
first_au_logged: bool,
}
impl Inner {
/// Log the first AU this context produces, exactly once. The presence of this line pairs a
/// `context #N created` bring-up with proof the encoder is live; its absence is the diagnostic
/// for the "no errors, just black" reconnect wedge.
fn note_first_au(&mut self, au: &EncodedFrame) {
if !self.first_au_logged {
self.first_au_logged = true;
tracing::info!(
bytes = au.data.len(),
keyframe = au.keyframe,
"AMF produced its first AU on this context"
);
}
}
}
pub struct AmfEncoder {
@@ -939,6 +988,14 @@ pub struct AmfEncoder {
/// gates [`EncoderCaps::intra_refresh`] so keyframe-request rate-limiting only happens when
/// the wave really runs.
ir_active: bool,
/// Consecutive [`reset`](Self::reset)s that have NOT been followed by a produced AU (cleared in
/// `poll` on any output). An in-place `Terminate`+re-`Init` heals a transient component stall,
/// but it re-inits the SAME context — so if the fault is the context / VCN session itself (the
/// AMD reconnect wedge), in-place recovery loops forever re-initing a dead session. Once this
/// reaches 2, `reset` escalates to a FULL context teardown (drop `inner`) so the next submit
/// brings up a brand-new `CreateContext`+`InitDX11` — which, once the prior session's VCN slot
/// has drained, actually encodes. Bounded by the session's `MAX_ENCODER_RESETS` either way.
resets_without_output: u32,
}
// SAFETY: `AmfEncoder` owns raw AMF interface pointers (context/component) and windows-rs COM
@@ -1027,6 +1084,7 @@ impl AmfEncoder {
force_kf: false,
hdr_meta: None,
ir_active: false,
resets_without_output: 0,
})
}
@@ -1309,9 +1367,17 @@ impl AmfEncoder {
let dctx = device
.GetImmediateContext()
.context("ID3D11Device immediate context")?;
// Bump AFTER a successful Init — a bring-up that failed above never counts. The
// sequence number is the reconnect axis: `context #1` is the first connection, `#2+`
// are reconnects; a climbing number with no following "first AU" line is the silent
// AMD wedge.
let context_no =
AMF_CONTEXTS_OPENED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
tracing::info!(
codec = ?self.codec,
"native AMF encode active ({}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
context = context_no,
device = format!("{:#x}", device.as_raw() as usize),
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
self.width,
self.height,
self.fps,
@@ -1330,6 +1396,7 @@ impl AmfEncoder {
pending: VecDeque::new(),
ready: VecDeque::new(),
hdr_pushed: None,
first_au_logged: false,
});
Ok(())
}
@@ -1846,35 +1913,53 @@ impl Encoder for AmfEncoder {
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
let odt = self.props.output_data_type;
let okm = self.props.output_key_max;
// Pull one AU (buffered or freshly queried) with the inner borrow scoped, so the produced
// AU can clear `resets_without_output` on `self` afterward without a borrow conflict.
let au = {
let Some(inner) = self.inner.as_mut() else {
return Ok(None);
};
// Back-pressure-buffered AUs first (strictly older than anything still in `pending`).
if let Some(au) = inner.ready.pop_front() {
return Ok(Some(au));
}
inner.note_first_au(&au);
Some(au)
} else {
let budget = std::time::Duration::from_micros(750_000 / self.fps.max(1) as u64)
.min(std::time::Duration::from_millis(12));
let deadline = std::time::Instant::now() + budget;
let mut out = None;
loop {
// SAFETY: `inner.comp.0` is the live component and `inner.pending` its FIFO, used only
// on this (encode) thread with no other AMF call to it in flight — `drain_one_output`'s
// documented contract.
// SAFETY: `inner.comp.0` is the live component and `inner.pending` its FIFO,
// used only on this (encode) thread with no other AMF call to it in flight —
// `drain_one_output`'s documented contract.
match unsafe { drain_one_output(inner.comp.0, &mut inner.pending, odt, okm) }? {
DrainOutcome::Frame(au) => return Ok(Some(au)),
DrainOutcome::Frame(au) => {
inner.note_first_au(&au);
out = Some(au);
break;
}
// Drained (post-`Drain`): nothing further is owed.
DrainOutcome::Eof => {
inner.pending.clear();
return Ok(None);
break;
}
DrainOutcome::NotReady => {}
}
// Not ready: only wait while a frame is actually owed, ~250 µs between checks.
if inner.pending.is_empty() || std::time::Instant::now() >= deadline {
return Ok(None);
break;
}
std::thread::sleep(std::time::Duration::from_micros(250));
}
out
}
};
// Any produced AU proves this context encodes — clear the no-output reset streak so a
// later, unrelated stall starts fresh at the cheap in-place recovery.
if au.is_some() {
self.resets_without_output = 0;
}
Ok(au)
}
/// Encode-stall recovery (design §3.5), cheaper than the ffmpeg path's drop-and-reopen:
@@ -1882,11 +1967,37 @@ impl Encoder for AmfEncoder {
/// same context. If the in-place rebuild fails, fall back to full teardown — the next
/// `submit` rebuilds context + component lazily, exactly like first-frame bring-up. Either
/// way the owed AUs are forfeited and the next frame is a forced IDR.
///
/// In-place re-`Init` reuses the SAME context, so it can't clear a fault that lives in the
/// context / VCN session (the AMD reconnect wedge: Init returns OK but the hardware session
/// never encodes). [`resets_without_output`](Self::resets_without_output) counts resets not
/// followed by an AU; once it reaches 2 this escalates to a FULL context teardown so the next
/// submit brings up a fresh `CreateContext`+`InitDX11` on a (by then) drained VCN slot.
fn reset(&mut self) -> bool {
self.force_kf = true;
let Some(inner) = self.inner.as_mut() else {
self.resets_without_output = self.resets_without_output.saturating_add(1);
if self.inner.is_none() {
return true; // nothing live — the next submit rebuilds lazily
};
}
// Escalate: an in-place re-Init already ran without producing an AU, so the fault is the
// context itself — tear it fully down and reopen fresh instead of re-initing a dead session
// in a loop until MAX_ENCODER_RESETS ends the whole session. Checked before borrowing
// `inner` so this can drop `self.inner`.
if self.resets_without_output >= 2 {
tracing::warn!(
resets = self.resets_without_output,
"AMF stall persisted across in-place re-Init — full context teardown, reopening a \
fresh context (next submit)"
);
self.inner = None;
self.bound_device = 0;
self.ir_active = false;
return true;
}
let inner = self
.inner
.as_mut()
.expect("inner is Some — checked above and not cleared since");
inner.pending.clear();
inner.ready.clear(); // owed + buffered AUs are forfeited; the rebuilt stream restarts at IDR
inner.hdr_pushed = None; // a re-Init'd component needs the HDR metadata again
+38 -9
View File
@@ -26,7 +26,9 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::config::{
mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
@@ -969,11 +971,14 @@ async fn serve_session(
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
max_data_per_block: 4096,
},
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: 1452,
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
// unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
// The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
// payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
// on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the
// client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES
// raised + end-to-end 9000 MTU).
shard_payload: mtu1500_shard_payload() as u16,
encrypt: true,
key,
salt: *b"pkf1",
@@ -1092,8 +1097,18 @@ async fn serve_session(
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
// is pinned via PUNKTFUNK_FEC_PCT.
if adaptive_fec {
let target = adapt_fec(rep.loss_ppm);
let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
// Fast attack, slow decay: jump straight to what the reported loss
// needs, but come DOWN only one point per clean report (~750 ms). The
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
// scans / BT coexistence, a burst every few seconds): a single clean
// window dropped FEC back to the floor, so every next burst hit an
// unprotected stream — an unrecoverable frame, a freeze, and a
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
// the stream covered across the gap while still converging to FEC_MIN
// on a genuinely clean link.
let prev = fec_target_ctl.load(Ordering::Relaxed);
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
fec_target_ctl.store(target, Ordering::Relaxed);
if prev != target {
tracing::info!(
loss_ppm = rep.loss_ppm,
@@ -3096,7 +3111,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?;
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")]
drop(_idd_setup_guard);
@@ -3280,6 +3295,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bit_depth,
plan,
&quit,
&stop,
)?;
Ok((new_vd, pipe))
})();
@@ -3486,6 +3502,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bit_depth,
plan,
&quit,
&stop,
) {
Ok(p) => break p,
Err(e2) => {
@@ -3793,6 +3810,7 @@ fn build_pipeline_with_retry(
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
stop: &Arc<AtomicBool>,
) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
@@ -3819,6 +3837,17 @@ fn build_pipeline_with_retry(
const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS {
// The client is gone (connection closed → `stop`): every further attempt only churns the
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
// 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it.
if attempt > 1 && stop.load(Ordering::SeqCst) {
anyhow::bail!(
"session ended (client disconnected) during pipeline build — aborting retries \
after {} attempt(s)",
attempt - 1
);
}
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
Ok(pipe) => {
if attempt > 1 {
@@ -824,19 +824,46 @@ fn kill_unit(unit: &str) {
.status();
}
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// Runtime-mask `unit` so the box's session supervisor cannot restart it underneath the takeover.
/// Bazzite/SteamOS autologin runs under SDDM with `Relogin=true` (`/etc/sddm.conf.d/steamos.conf`):
/// the moment the autologin session dies — including our own deliberate stop — SDDM logs back in and
/// starts the unit again within the same second. A merely-stopped unit then fights our host-managed
/// session over the Steam single instance and the GPU for the whole stream (the restarted wrapper
/// relaunches gamescope every ~7 s; the contention SIGSEGVs gamescopes and eventually kills the
/// streaming one — the "stream dies after 30 s5 min" field reports, diagnosed live on .181
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
/// without restoring (the same semantics as the persisted takeover file).
fn mask_unit(unit: &str) {
let _ = Command::new("systemctl")
.args(["--user", "mask", "--runtime", unit])
.status();
}
/// Undo [`mask_unit`] — every restore path must unmask before (or regardless of) restarting, or the
/// box's own return-to-gaming-mode stays broken until reboot.
fn unmask_unit(unit: &str) {
let _ = Command::new("systemctl")
.args(["--user", "unmask", "--runtime", unit])
.status();
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`]
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
/// fight the moment the supervisor restarts it.
fn stop_autologin_sessions() {
let Ok(out) = Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--state=running",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
@@ -849,10 +876,11 @@ fn stop_autologin_sessions() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(unit) = line.split_whitespace().next() {
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
tracing::info!(
unit,
"freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
);
stopped.push(unit.to_string());
}
@@ -980,6 +1008,11 @@ fn do_restore_tv_session() {
}
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
// Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked
// would break the user's own return to gaming mode until reboot.
for unit in &units {
unmask_unit(unit);
}
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
@@ -1095,6 +1128,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
let wrapper = write_gamescope_bin_wrapper()?;
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
let hz = mode.refresh_hz.max(1);
let start_unit = || -> Result<()> {
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
.arg("--setenv=BACKEND=headless")
@@ -1109,12 +1143,17 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
.arg(client)
.status()
.context(
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \
up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd \
manager up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
)?;
if !status.success() {
anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})");
anyhow::bail!(
"`systemd-run --user` failed to start the gamescope session (exit {status})"
);
}
Ok(())
};
start_unit()?;
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
@@ -1128,10 +1167,45 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
);
}
// The session-plus wrapper hard-kills a gamescope that missed its 5 s readiness handshake
// and exits 1 (a slow NVIDIA cold start routinely needs 5-15 s — the .181 storm 2026-07-07),
// and the transient unit has no Restart= — without supervision the rest of this poll would
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
if !unit_starting_or_active(unit_name) {
tracing::info!(
unit = unit_name,
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
readiness window?) — relaunching"
);
// Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init,
// and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays
// the reclaim serialization on top of device init and misses the 5 s window again.
std::thread::sleep(Duration::from_millis(1500));
let _ = Command::new("systemctl")
.args(["--user", "reset-failed", unit_name])
.status();
start_unit()?;
}
std::thread::sleep(Duration::from_millis(500));
}
}
/// Is the unit currently starting or up (`activating` / `active` — also `deactivating`: let a stop
/// finish; the next poll tick sees the settled state)? Unknown/unreachable states report `true` so a
/// systemctl hiccup can't trigger a relaunch storm.
fn unit_starting_or_active(unit: &str) -> bool {
let Ok(out) = Command::new("systemctl")
.args(["--user", "is-active", unit])
.output()
else {
return true;
};
matches!(
String::from_utf8_lossy(&out.stdout).trim(),
"active" | "activating" | "reloading" | "deactivating"
)
}
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
fn stop_session(unit_name: &str) {