Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f617a989 | ||
|
|
2f071a9a93 | ||
|
|
62d35bc4b6 | ||
|
|
5d06ef26ac | ||
|
|
42a0dd52be | ||
|
|
9fb41affba | ||
|
|
2d43275fcb | ||
|
|
77ddd05b13 |
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
private class GpRow(
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val header: String?,
|
||||
val label: String,
|
||||
@@ -78,6 +78,15 @@ private class GpRow(
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
/**
|
||||
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
|
||||
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
|
||||
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
|
||||
* else, and every dimmed row still stepped its setting.
|
||||
*/
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
onActivate = { adjustDir = 1; liveRow(rows, 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.
|
||||
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
private fun buildSettingsRows(
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
av1Capable: Boolean,
|
||||
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
@@ -478,22 +494,26 @@ private fun buildSettingsRows(
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
// Everything below the master switch follows it — dim and inert while nothing is being
|
||||
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture,
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
@@ -513,8 +533,18 @@ private fun buildSettingsRows(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture,
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
// The SC2 row's twin, and missing here until now: the touch settings have carried both
|
||||
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
|
||||
* governed row is inert rather than merely dim.
|
||||
*
|
||||
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
|
||||
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
|
||||
* these tests pin both halves — the flag AND the refusal to write.
|
||||
*/
|
||||
class GamepadSettingsRowsTest {
|
||||
|
||||
/** Rows for a given forwarding state, capturing whatever a row writes back. */
|
||||
private fun rows(
|
||||
forwarding: Boolean,
|
||||
sink: MutableList<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow =
|
||||
rows.first { it.id == id }
|
||||
|
||||
/** Every row that only means something while a controller is actually being forwarded. */
|
||||
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
|
||||
|
||||
@Test
|
||||
fun `forwarding off dims every row that depends on it`() {
|
||||
val off = rows(forwarding = false)
|
||||
for (id in governed) {
|
||||
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
|
||||
}
|
||||
// The master switch itself stays live — otherwise it could never be turned back on.
|
||||
assertTrue(row(off, "padForward").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarding on leaves them all live`() {
|
||||
val on = rows(forwarding = true)
|
||||
for (id in governed) {
|
||||
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val off = rows(forwarding = false, sink = writes)
|
||||
for (id in governed) {
|
||||
val i = off.indexOfFirst { it.id == id }
|
||||
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
|
||||
// What the screen actually does on left/right/A — the whole point is that it no-ops.
|
||||
liveRow(off, i)?.adjust(1)
|
||||
liveRow(off, i)?.adjust(-1)
|
||||
liveRow(off, i)?.activate()
|
||||
}
|
||||
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val on = rows(forwarding = true, sink = writes)
|
||||
val i = on.indexOfFirst { it.id == "sc2" }
|
||||
assertNotNull(liveRow(on, i))
|
||||
liveRow(on, i)?.activate()
|
||||
assertEquals(1, writes.size)
|
||||
assertFalse("activate flips the toggle", writes[0].sc2Capture)
|
||||
}
|
||||
|
||||
/**
|
||||
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
|
||||
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
|
||||
*/
|
||||
@Test
|
||||
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
|
||||
val on = rows(forwarding = true)
|
||||
val ids = on.map { it.id }
|
||||
assertTrue("dsCapture row is missing", "dsCapture" in ids)
|
||||
assertEquals(
|
||||
"the two passthrough rows belong side by side",
|
||||
ids.indexOf("sc2") + 1,
|
||||
ids.indexOf("dsCapture"),
|
||||
)
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
}
|
||||
@@ -279,8 +279,8 @@ object DsDevice {
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
it[3] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,17 +324,11 @@ object DsDevice {
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,15 +131,9 @@ class GamepadFeedback(
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
|
||||
// against the Rust packer.
|
||||
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
@@ -147,12 +141,7 @@ class GamepadFeedback(
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
@@ -292,8 +281,8 @@ class GamepadFeedback(
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
@@ -342,8 +331,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -357,12 +346,6 @@ class GamepadFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The two conversions every rumble path in this module needs, in one place.
|
||||
*
|
||||
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
|
||||
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
|
||||
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
|
||||
* silent divergence between copies would have been hard to notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
|
||||
*
|
||||
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
|
||||
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
|
||||
* imperceptibly light, but it moves.
|
||||
*/
|
||||
internal fun wireAmplitudeToByte(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
|
||||
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
|
||||
|
||||
/**
|
||||
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
|
||||
*
|
||||
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
|
||||
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
|
||||
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
|
||||
* compile-time assertion tying the two together, so this can't silently start truncating.
|
||||
*
|
||||
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
|
||||
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
|
||||
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
|
||||
*/
|
||||
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
|
||||
if (ev < 0L) return null // timeout / closed
|
||||
return RumbleCmd(
|
||||
pad = ((ev ushr 49) and 0xFL).toInt(),
|
||||
low = ((ev ushr 16) and 0xFFFF).toInt(),
|
||||
high = (ev and 0xFFFF).toInt(),
|
||||
backstopMs = (ev ushr 32) and 0xFFFF,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
|
||||
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
|
||||
* sides, which is the only thing that catches one of them drifting.
|
||||
*/
|
||||
class RumbleWireTest {
|
||||
|
||||
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
|
||||
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
|
||||
((pad and 0xF).toLong() shl 49) or
|
||||
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
|
||||
(low.toLong() shl 16) or
|
||||
high.toLong()
|
||||
|
||||
@Test
|
||||
fun `every field round-trips at its extremes`() {
|
||||
val cases = listOf(
|
||||
listOf(0, 0, 0, 0),
|
||||
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
listOf(1, 0x1234, 0x5678, 500),
|
||||
listOf(7, 0, 0xFFFF, 2000),
|
||||
)
|
||||
for ((pad, low, high, backstop) in cases) {
|
||||
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
|
||||
assertEquals("pad", pad, cmd.pad)
|
||||
assertEquals("low", low, cmd.low)
|
||||
assertEquals("high", high, cmd.high)
|
||||
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
|
||||
@Test
|
||||
fun `all sixteen pad indices are distinct`() {
|
||||
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
|
||||
assertEquals((0 until 16).toList(), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the negative sentinel is not a command`() {
|
||||
assertNull(unpackRumbleEvent(-1L))
|
||||
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop is distinguishable from a hold`() {
|
||||
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
|
||||
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
|
||||
assertEquals(0, stop.low)
|
||||
assertEquals(0, stop.high)
|
||||
assertNotEquals(stop, hold)
|
||||
}
|
||||
|
||||
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
|
||||
|
||||
@Test
|
||||
fun `amplitude takes the high byte`() {
|
||||
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
|
||||
assertEquals(0x80, wireAmplitudeToByte(0x8000))
|
||||
assertEquals(0x12, wireAmplitudeToByte(0x1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero stays silent but a weak nonzero never does`() {
|
||||
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
|
||||
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
|
||||
for (v in listOf(1, 0x0042, 0x00FF)) {
|
||||
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
|
||||
}
|
||||
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,29 @@ use std::time::Duration;
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
|
||||
const PAD_BITS: u32 = 4;
|
||||
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
|
||||
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
|
||||
/// rather than failing the build.
|
||||
const _: () = assert!(
|
||||
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
|
||||
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
|
||||
);
|
||||
|
||||
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
|
||||
///
|
||||
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
|
||||
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
|
||||
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
|
||||
/// without a live session handle — the shift arithmetic is the part worth pinning.
|
||||
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
|
||||
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
|
||||
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
@@ -54,12 +77,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
@@ -160,3 +178,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_rumble_tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
|
||||
/// broken, and nothing else in the build would say so.
|
||||
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
|
||||
let pad = ((ev >> 49) & 0xF) as u16;
|
||||
let backstop = ((ev >> 32) & 0xFFFF) as u32;
|
||||
let low = ((ev >> 16) & 0xFFFF) as u16;
|
||||
let high = (ev & 0xFFFF) as u16;
|
||||
(pad, low, high, backstop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_field_at_its_extremes() {
|
||||
for &(pad, low, high, backstop) in &[
|
||||
(0u16, 0u16, 0u16, 0u32),
|
||||
(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
(1, 0x1234, 0x5678, 500),
|
||||
(7, 0, 0xFFFF, 2000),
|
||||
] {
|
||||
let ev = pack_rumble(pad, low, high, backstop);
|
||||
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_representable_pad_survives_the_four_bit_field() {
|
||||
for pad in 0..MAX_PADS as u16 {
|
||||
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
|
||||
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_command_is_never_negative() {
|
||||
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
|
||||
// "no command" and the rumble would simply vanish.
|
||||
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
|
||||
assert!(pack_rumble(0, 0, 0, 0) >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
|
||||
let ev = pack_rumble(3, 0, 0, u32::MAX);
|
||||
let (pad, _, _, backstop) = unpack(ev);
|
||||
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
|
||||
assert_eq!(backstop, 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_is_distinguishable_from_a_hold() {
|
||||
let stop = pack_rumble(2, 0, 0, 0);
|
||||
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
|
||||
assert_ne!(stop, hold);
|
||||
assert_eq!(unpack(stop).1, 0);
|
||||
assert_eq!(unpack(stop).2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@ struct GamepadSettingsView: View {
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
@@ -218,7 +223,8 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -239,9 +245,13 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
// Contents only — the glass and border below stay at full strength, so a dimmed row
|
||||
// still reads as a row you can sit on (which you can: its detail is the point).
|
||||
.opacity(row.enabled ? 1 : 0.45)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
@@ -276,6 +286,13 @@ struct GamepadSettingsView: View {
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
|
||||
/// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the
|
||||
/// user learns which switch to flip first, and a row that vanished mid-list would
|
||||
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -286,12 +303,14 @@ struct GamepadSettingsView: View {
|
||||
/// (never on state captured at wire time).
|
||||
private func adjust(id: String, by delta: Int) -> Bool {
|
||||
lastAdjustDelta = delta
|
||||
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
|
||||
return row.adjust(delta)
|
||||
}
|
||||
|
||||
private func activate(id: String) {
|
||||
lastAdjustDelta = 1 // A always cycles forward
|
||||
rows.first { $0.id == id }?.activate()
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
|
||||
row.activate()
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
@@ -391,27 +410,35 @@ struct GamepadSettingsView: View {
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
// The four rows below only mean something while something is being forwarded, so
|
||||
// they follow the switch above — the same relationship the touch settings draw with
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
enabled: gamepadForwarding
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
@@ -583,13 +610,15 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
guard let index else {
|
||||
@@ -610,12 +639,13 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
let target = delta > 0
|
||||
|
||||
@@ -12,7 +12,7 @@ import GameController
|
||||
public final class ControllerTester: ObservableObject {
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
|
||||
@@ -98,8 +98,17 @@ public final class GamepadCapture {
|
||||
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
|
||||
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
|
||||
/// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD.
|
||||
private static let escapeChord: UInt32 =
|
||||
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
|
||||
/// `escapeChordElements` below — the two must not drift.
|
||||
static let escapeChord: UInt32 =
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed
|
||||
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
|
||||
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
|
||||
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
|
||||
static let escapeChordElements = [
|
||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||
]
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
@@ -236,7 +245,17 @@ public final class GamepadCapture {
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
//
|
||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
||||
// complete and the session would have no controller exit at all.)
|
||||
let claimed = forwarding
|
||||
? Array(c.physicalInputProfile.elements.values)
|
||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
for element in claimed {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
@@ -276,7 +295,11 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||
}
|
||||
}
|
||||
if let motion = c.motion {
|
||||
// Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no
|
||||
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
|
||||
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
|
||||
// it back down), so with nothing to forward we simply never turn it on.
|
||||
if forwarding, let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
deviceRumble = RumbleRenderer(actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public final class GamepadFeedback {
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
|
||||
@@ -43,8 +43,14 @@ enum RumbleTuning {
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence:
|
||||
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
|
||||
/// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's
|
||||
/// `toAmplitude` has always done this; this was the odd one out.)
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 {
|
||||
let b = UInt8(wire >> 8)
|
||||
return wire != 0 && b == 0 ? 1 : b
|
||||
}
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
@@ -81,10 +87,11 @@ enum RumbleTuning {
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
|
||||
/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host
|
||||
/// staleness, session close — now belongs to punktfunk-core's shared policy engine
|
||||
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
|
||||
/// is told and never decides on its own when a level should end.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
@@ -93,17 +100,6 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
init(actuator: Actuator = .controller) {
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import GameController
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The escape chord's mask and its GameController alias list have to describe the same four
|
||||
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
||||
/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops
|
||||
/// covering the mask, the missing button's press stays the system's and the chord never completes.
|
||||
///
|
||||
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
||||
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
||||
/// Hence a test on the invariant rather than trusting the comment beside it.
|
||||
@MainActor
|
||||
final class GamepadEscapeChordTests: XCTestCase {
|
||||
|
||||
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
||||
private let pairing: [(alias: String, bit: UInt32)] = [
|
||||
(GCInputLeftShoulder, GamepadWire.leftShoulder),
|
||||
(GCInputRightShoulder, GamepadWire.rightShoulder),
|
||||
(GCInputButtonMenu, GamepadWire.start),
|
||||
(GCInputButtonOptions, GamepadWire.back),
|
||||
]
|
||||
|
||||
func testChordMaskIsExactlyTheFourPairedButtons() {
|
||||
XCTAssertEqual(
|
||||
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
||||
GamepadCapture.escapeChord,
|
||||
"the chord mask and the alias pairing describe different buttons")
|
||||
}
|
||||
|
||||
func testEveryChordBitHasAnElementToClaim() {
|
||||
// One alias per bit — a mask that grew a fifth button without a matching alias would
|
||||
// leave that button's gesture with the OS while forwarding is off.
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.escapeChordElements.count,
|
||||
GamepadCapture.escapeChord.nonzeroBitCount,
|
||||
"alias list and chord mask differ in size")
|
||||
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
|
||||
}
|
||||
|
||||
/// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of
|
||||
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
|
||||
/// is the behaviour this deliberately avoids).
|
||||
func testClaimListIsNonEmptyAndAllDistinct() {
|
||||
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
|
||||
XCTAssertEqual(
|
||||
Set(GamepadCapture.escapeChordElements).count,
|
||||
GamepadCapture.escapeChordElements.count,
|
||||
"a repeated alias would mean a chord bit has no element")
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
|
||||
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
renderer.apply(low: 0x8000, high: 0x8000)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
@@ -981,6 +981,13 @@ pub(crate) fn settings_page(
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
s.save();
|
||||
})
|
||||
// Dimmed with the master switch above it, like echo cancellation under the mic
|
||||
// (see that row) — this and the three below have nothing to act on while no
|
||||
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
|
||||
// screen, so they follow the toggle live. Brings this client in line with how GTK
|
||||
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
|
||||
// console UI (dim + refuse the step) have always drawn the same relationship.
|
||||
.enabled(s.gamepad_forwarding)
|
||||
};
|
||||
let pad_forward_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
|
||||
@@ -991,7 +998,8 @@ pub(crate) fn settings_page(
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
})
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
|
||||
let sysbtn_combo = setting_combo(
|
||||
ctx,
|
||||
@@ -1002,7 +1010,8 @@ pub(crate) fn settings_page(
|
||||
|s, i| {
|
||||
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
)
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
|
||||
let gesture_combo = setting_combo(
|
||||
ctx,
|
||||
@@ -1013,7 +1022,8 @@ pub(crate) fn settings_page(
|
||||
|s, i| {
|
||||
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
|
||||
},
|
||||
);
|
||||
)
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
|
||||
@@ -732,13 +732,27 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
|
||||
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||
/// through SDL) and untouched fields keep their state.
|
||||
///
|
||||
/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading
|
||||
/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and
|
||||
/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is
|
||||
/// host-side and neither crate depends on the other, and a DualSense report layout has no business
|
||||
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
|
||||
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than
|
||||
/// leaving it to a comment.
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
const LED_RGB: usize = 44;
|
||||
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
||||
/// values so the subtraction is visible at the point of definition.
|
||||
const REPORT_ID_LEN: usize = 1;
|
||||
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
||||
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
||||
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
||||
const LED_RGB: usize = 45 - Self::REPORT_ID_LEN;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors
|
||||
/// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary.
|
||||
const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize;
|
||||
|
||||
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
@@ -748,7 +762,7 @@ impl Ds5Feedback {
|
||||
(0x08, Self::LEFT_TRIGGER)
|
||||
};
|
||||
p[0] = flag;
|
||||
let n = effect.len().min(11);
|
||||
let n = effect.len().min(Self::TRIGGER_LEN);
|
||||
p[off..off + n].copy_from_slice(&effect[..n]);
|
||||
p
|
||||
}
|
||||
@@ -1917,7 +1931,12 @@ impl Worker {
|
||||
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||
100 // a stop takes effect immediately; the duration is irrelevant
|
||||
} else {
|
||||
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||
// No local floor. There was a `.max(160)` here, and it could never do anything: the
|
||||
// engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy
|
||||
// value, so a non-zero command's backstop is never below 500. A floor that belongs to a
|
||||
// particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine
|
||||
// already applies — not re-invented per renderer where it can silently disagree.
|
||||
backstop_ms
|
||||
};
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||
@@ -2504,6 +2523,134 @@ mod slot_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple
|
||||
/// writer are all pinned by their own suites; this writer had nothing, despite being the one that
|
||||
/// hand-shifts every offset by the report-id length.
|
||||
#[cfg(test)]
|
||||
mod ds5_feedback_tests {
|
||||
use super::*;
|
||||
|
||||
/// The USB output report offsets, written out independently of the implementation. A DS5
|
||||
/// effects payload is the same block with the leading report id removed, so every offset is
|
||||
/// exactly one lower — this is the relationship the derived constants encode.
|
||||
#[test]
|
||||
fn ds5_offsets_track_the_usb_report() {
|
||||
for (usb, payload) in [
|
||||
(11usize, Ds5Feedback::RIGHT_TRIGGER),
|
||||
(22, Ds5Feedback::LEFT_TRIGGER),
|
||||
(44, Ds5Feedback::PAD_LIGHTS),
|
||||
(45, Ds5Feedback::LED_RGB),
|
||||
] {
|
||||
assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}");
|
||||
}
|
||||
assert_eq!(Ds5Feedback::TRIGGER_LEN, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() {
|
||||
let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33);
|
||||
assert_eq!(p.len(), 47);
|
||||
assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit");
|
||||
assert_eq!(p[0], 0, "must not claim any valid_flag0 field");
|
||||
assert_eq!(
|
||||
(
|
||||
p[Ds5Feedback::LED_RGB],
|
||||
p[Ds5Feedback::LED_RGB + 1],
|
||||
p[Ds5Feedback::LED_RGB + 2]
|
||||
),
|
||||
(0x11, 0x22, 0x33)
|
||||
);
|
||||
// Everything else stays zero — an over-broad packet would blank the triggers/player LEDs
|
||||
// it never meant to touch.
|
||||
let touched = [
|
||||
1,
|
||||
Ds5Feedback::LED_RGB,
|
||||
Ds5Feedback::LED_RGB + 1,
|
||||
Ds5Feedback::LED_RGB + 2,
|
||||
];
|
||||
assert!(p
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &b)| touched.contains(&i) || b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leds_are_masked_to_five_bits() {
|
||||
let p = Ds5Feedback::player_packet(0xFF);
|
||||
assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit");
|
||||
assert_eq!(
|
||||
p[Ds5Feedback::PAD_LIGHTS],
|
||||
0x1F,
|
||||
"high bits are not ours to set"
|
||||
);
|
||||
let p = Ds5Feedback::player_packet(0b0000_0101);
|
||||
assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101);
|
||||
}
|
||||
|
||||
/// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the
|
||||
/// pairing most likely to be transcribed backwards.
|
||||
#[test]
|
||||
fn trigger_which_selects_the_right_flag_and_offset() {
|
||||
let eff: Vec<u8> = (1..=11).collect();
|
||||
|
||||
let r = Ds5Feedback::trigger_packet(1, &eff);
|
||||
assert_eq!(r[0], 0x04, "valid_flag0 R2 bit");
|
||||
assert_eq!(
|
||||
&r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(
|
||||
r[Ds5Feedback::LEFT_TRIGGER],
|
||||
0,
|
||||
"the other trigger is untouched"
|
||||
);
|
||||
|
||||
let l = Ds5Feedback::trigger_packet(0, &eff);
|
||||
assert_eq!(l[0], 0x08, "valid_flag0 L2 bit");
|
||||
assert_eq!(
|
||||
&l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11],
|
||||
&eff[..]
|
||||
);
|
||||
assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() {
|
||||
let long = vec![0xAAu8; 40];
|
||||
let p = Ds5Feedback::trigger_packet(1, &long);
|
||||
assert_eq!(p.len(), 47);
|
||||
// Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on.
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA);
|
||||
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_effect_leaves_the_rest_of_the_block_zeroed() {
|
||||
let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02);
|
||||
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99);
|
||||
assert!(
|
||||
p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert
|
||||
/// its enable bit, or the pad keeps whatever effect it was holding.
|
||||
#[test]
|
||||
fn an_empty_effect_is_a_release_not_a_no_op() {
|
||||
let p = Ds5Feedback::trigger_packet(1, &[]);
|
||||
assert_eq!(p[0], 0x04);
|
||||
assert!(
|
||||
p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reset_packet_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
|
||||
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
|
||||
@@ -300,7 +300,6 @@ impl Effect {
|
||||
/// the policy is pure and unit-testable without a live uinput fd.
|
||||
struct FfState {
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
@@ -316,7 +315,6 @@ impl FfState {
|
||||
fn new() -> FfState {
|
||||
FfState {
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
last_activity: Instant::now(),
|
||||
@@ -575,11 +573,13 @@ impl VirtualPad {
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.ff.next_effect_id;
|
||||
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
let e = up.effect;
|
||||
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
|
||||
// writes it into the effect BEFORE handing the request to uinput, so what
|
||||
// arrives here is always an assigned id. The fallback that used to allocate
|
||||
// one from a local counter could therefore never run, and a local counter is
|
||||
// the wrong answer anyway — the kernel owns that id space.
|
||||
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::steam_proto::{
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
|
||||
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
|
||||
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
@@ -214,10 +200,13 @@ impl SteamDeckPad {
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
let id = request_id(&ev);
|
||||
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
|
||||
// declared — this used to read a fixed 16-byte window, which truncated any
|
||||
// longer report and, for a shorter one, fed the parser whatever the reused
|
||||
// event buffer still held past the payload. Every sibling backend that parses
|
||||
// SET_REPORT already read the size field; this one didn't.
|
||||
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::triton_proto::{
|
||||
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
|
||||
TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
|
||||
@@ -22,6 +22,10 @@ use super::switch_proto::{
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
|
||||
//!
|
||||
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
|
||||
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
|
||||
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
|
||||
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
|
||||
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
|
||||
//! [`set_report_data`]).
|
||||
//!
|
||||
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
|
||||
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
|
||||
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
|
||||
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
|
||||
//! subtly wrong.
|
||||
|
||||
/// The character device every backend opens.
|
||||
pub const UHID_PATH: &str = "/dev/uhid";
|
||||
|
||||
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
|
||||
pub const UHID_DESTROY: u32 = 1;
|
||||
pub const UHID_OUTPUT: u32 = 6;
|
||||
pub const UHID_GET_REPORT: u32 = 9;
|
||||
pub const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
pub const UHID_CREATE2: u32 = 11;
|
||||
pub const UHID_INPUT2: u32 = 12;
|
||||
pub const UHID_SET_REPORT: u32 = 13;
|
||||
pub const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
|
||||
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
|
||||
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
|
||||
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
/// `BUS_USB` from `linux/input.h`.
|
||||
pub const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
|
||||
const OFF_ID: usize = 4;
|
||||
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
|
||||
const OFF_SET_REPORT_SIZE: usize = 10;
|
||||
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
|
||||
const OFF_DATA: usize = 12;
|
||||
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
|
||||
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
|
||||
/// truncation still leaves a NUL terminator.
|
||||
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
|
||||
pub fn request_id(ev: &[u8]) -> u32 {
|
||||
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
|
||||
///
|
||||
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
|
||||
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
|
||||
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
|
||||
/// report means acting on numbers the game never wrote.
|
||||
pub fn set_report_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
|
||||
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[OFF_DATA.min(end)..end]
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
|
||||
pub fn output_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
|
||||
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[4.min(end)..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank() -> Vec<u8> {
|
||||
vec![0u8; UHID_EVENT_SIZE]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_honours_the_events_own_size() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
|
||||
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
|
||||
ev[OFF_DATA + i] = *b;
|
||||
}
|
||||
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
|
||||
ev[OFF_DATA + 5] = 0xAA;
|
||||
ev[OFF_DATA + 15] = 0xBB;
|
||||
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_is_not_truncated_at_sixteen() {
|
||||
let mut ev = blank();
|
||||
let n = 40usize;
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
|
||||
for i in 0..n {
|
||||
ev[OFF_DATA + i] = i as u8;
|
||||
}
|
||||
let d = set_report_data(&ev);
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
n,
|
||||
"a report longer than 16 bytes must survive whole"
|
||||
);
|
||||
assert_eq!(d[39], 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_and_empty_sizes_stay_in_bounds() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
|
||||
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
|
||||
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
|
||||
|
||||
let ev0 = blank(); // size = 0
|
||||
assert!(set_report_data(&ev0).is_empty());
|
||||
assert!(output_data(&ev0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_data_reads_its_trailing_size_field() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
|
||||
ev[4] = 0x02;
|
||||
ev[5] = 0x11;
|
||||
ev[6] = 0x22;
|
||||
ev[7] = 0x33; // past the declared size
|
||||
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_id_round_trips() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
|
||||
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
#[derive(Default)]
|
||||
pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
/// `(low, high)` motor levels, if a report carried them.
|
||||
///
|
||||
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
|
||||
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
|
||||
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
|
||||
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
|
||||
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
|
||||
/// the type that sees both.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
|
||||
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
|
||||
@@ -487,64 +494,101 @@ pub struct DsFeedback {
|
||||
pub resync: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
|
||||
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
|
||||
/// down; index off these rather than repeating the numbers.
|
||||
///
|
||||
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
|
||||
/// lays out one common block; what changes is how much header precedes it:
|
||||
///
|
||||
/// | base | where | first payload byte |
|
||||
/// |---|---|---|
|
||||
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
|
||||
/// | `−1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
|
||||
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
|
||||
///
|
||||
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
|
||||
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
|
||||
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
|
||||
pub mod out_report {
|
||||
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
|
||||
pub const VALID_FLAG0: usize = 1;
|
||||
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
|
||||
pub const VALID_FLAG1: usize = 2;
|
||||
/// High-frequency (small / right) motor.
|
||||
pub const MOTOR_RIGHT: usize = 3;
|
||||
/// Low-frequency (big / left) motor.
|
||||
pub const MOTOR_LEFT: usize = 4;
|
||||
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
|
||||
pub const RIGHT_TRIGGER: usize = 11;
|
||||
/// First byte of the LEFT trigger's parameter block.
|
||||
pub const LEFT_TRIGGER: usize = 22;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
|
||||
pub const TRIGGER_LEN: usize = 11;
|
||||
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
|
||||
pub const VALID_FLAG2: usize = 39;
|
||||
/// Lit player-indicator bits (low 5).
|
||||
pub const PLAYER_LEDS: usize = 44;
|
||||
/// Lightbar red; green and blue follow.
|
||||
pub const LED_RGB: usize = 45;
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
||||
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
||||
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
|
||||
/// broadcast.
|
||||
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
use out_report as o;
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
|
||||
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
|
||||
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
|
||||
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
|
||||
// kernel and SDL write the v2 flag — while older writers, and any that never read the
|
||||
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
|
||||
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
|
||||
// the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
|
||||
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
|
||||
let low = (data[o::MOTOR_LEFT] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
bits: data[o::PLAYER_LEDS] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
|
||||
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ use std::time::{Duration, Instant};
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
/// `(low, high)` motor levels, if the pass saw a rumble report.
|
||||
///
|
||||
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
|
||||
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
|
||||
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
|
||||
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
|
||||
|
||||
@@ -457,6 +457,11 @@ pub mod triton_proto;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/triton_usbip.rs"]
|
||||
pub mod triton_usbip;
|
||||
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
|
||||
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/uhid_abi.rs"]
|
||||
pub mod uhid_abi;
|
||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
|
||||
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
|
||||
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
|
||||
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
|
||||
# note above says, a clashing #define silently takes the last definition rather than failing to
|
||||
# compile. The table above had been doing this by hand for the handful someone noticed; this is
|
||||
# the rest of them, so the stated rule finally holds for the whole surface.
|
||||
#
|
||||
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
|
||||
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
|
||||
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
|
||||
# namespaced, just not by us.
|
||||
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
|
||||
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
|
||||
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
|
||||
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
|
||||
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
|
||||
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
|
||||
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
|
||||
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
|
||||
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
|
||||
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
|
||||
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
|
||||
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
|
||||
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
|
||||
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
|
||||
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
|
||||
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
|
||||
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
|
||||
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
|
||||
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
|
||||
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
|
||||
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
|
||||
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
|
||||
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
|
||||
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
|
||||
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
|
||||
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
|
||||
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
|
||||
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
|
||||
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
|
||||
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
|
||||
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
|
||||
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
|
||||
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
|
||||
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
|
||||
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
|
||||
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
|
||||
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
|
||||
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
|
||||
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
|
||||
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
|
||||
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
|
||||
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
|
||||
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
|
||||
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
|
||||
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
|
||||
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
|
||||
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
|
||||
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
|
||||
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
|
||||
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
|
||||
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
|
||||
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
|
||||
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
|
||||
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
|
||||
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
|
||||
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
|
||||
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
|
||||
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
|
||||
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
|
||||
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
|
||||
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
|
||||
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
|
||||
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
|
||||
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
|
||||
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
|
||||
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
|
||||
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
|
||||
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
|
||||
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
|
||||
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
|
||||
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
|
||||
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
|
||||
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
|
||||
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
|
||||
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
|
||||
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
|
||||
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
|
||||
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
|
||||
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
|
||||
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
|
||||
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
|
||||
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
|
||||
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
|
||||
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
|
||||
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
|
||||
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
|
||||
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
|
||||
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
|
||||
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
|
||||
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
|
||||
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
|
||||
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
|
||||
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
|
||||
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
|
||||
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
|
||||
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
|
||||
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
|
||||
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
|
||||
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
|
||||
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
|
||||
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
|
||||
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
|
||||
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
|
||||
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
|
||||
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
|
||||
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
|
||||
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
|
||||
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
|
||||
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
|
||||
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
|
||||
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
|
||||
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
|
||||
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
|
||||
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
|
||||
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
|
||||
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
|
||||
"PRESETS" = "PUNKTFUNK_PRESETS"
|
||||
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
|
||||
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
|
||||
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
|
||||
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
|
||||
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
|
||||
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
|
||||
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
|
||||
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
|
||||
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
|
||||
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
|
||||
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
|
||||
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
|
||||
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
|
||||
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
|
||||
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
|
||||
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
|
||||
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
|
||||
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
|
||||
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
|
||||
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
|
||||
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
|
||||
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
|
||||
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
|
||||
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
|
||||
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
[enum]
|
||||
|
||||
@@ -698,7 +698,10 @@ pub struct PunktfunkHidOutput {
|
||||
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
|
||||
pub effect_len: u8,
|
||||
/// Trigger: the raw DualSense trigger parameter block (mode + params).
|
||||
pub effect: [u8; 11],
|
||||
/// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
|
||||
/// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
/// number while the struct it describes hardcoded another was the whole hazard.
|
||||
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -2497,10 +2500,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||
/// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
|
||||
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
|
||||
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
|
||||
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||
/// actuator.
|
||||
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
|
||||
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
|
||||
@@ -60,22 +60,28 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// A pad index the client cannot represent is dropped outright, before either
|
||||
// consumer sees it. It used to be waved through: the seq gate was skipped (its
|
||||
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
|
||||
// while the policy engine silently discarded it on its own bounds check — so
|
||||
// "both consumers are fed" below was false for exactly these, and an embedder
|
||||
// draining the queue could be handed an index it would use to subscript its
|
||||
// own per-pad array. The host never emits one; this is malformed or hostile.
|
||||
let idx = u.pad as usize;
|
||||
if idx >= crate::input::MAX_PADS {
|
||||
continue;
|
||||
}
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
|
||||
@@ -69,10 +69,25 @@ pub struct RumbleCommand {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ActuatorQuirks {
|
||||
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
|
||||
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
|
||||
/// `0` = no keepalive (the common case).
|
||||
/// output decays between wire renewals. `0` = no keepalive (the common case).
|
||||
///
|
||||
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
|
||||
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
|
||||
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
|
||||
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
|
||||
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
|
||||
/// command carrying the same levels, and that renderer skips a HID write whose levels are
|
||||
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
|
||||
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
|
||||
pub keepalive_ms: u16,
|
||||
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
|
||||
/// Floor for `backstop_ms` on non-zero commands.
|
||||
///
|
||||
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
|
||||
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
|
||||
/// rejects short values. The case it was written for is handled elsewhere: Android's
|
||||
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
|
||||
/// duration itself at the call, and that path never declares quirks at all. Kept because it
|
||||
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
|
||||
pub min_pulse_ms: u16,
|
||||
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
|
||||
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
|
||||
|
||||
@@ -107,6 +107,10 @@ pub use stats::Stats;
|
||||
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
/// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -120,7 +124,15 @@ pub use stats::Stats;
|
||||
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 14;
|
||||
/// v15: versions the shared rumble policy engine's C surface —
|
||||
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
/// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 15;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -401,6 +401,16 @@ impl RichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
/// into its report.
|
||||
///
|
||||
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
/// been bounded on both ends all along.
|
||||
pub const TRIGGER_EFFECT_MAX: usize = 11;
|
||||
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
@@ -431,6 +441,14 @@ pub enum HidOutput {
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
///
|
||||
/// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host
|
||||
/// produces this variant and no client renders it; it codes/decodes and round-trips in tests
|
||||
/// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a
|
||||
/// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still
|
||||
/// send it), it would only lose the decoder that keeps such a datagram from being mistaken
|
||||
/// for something else. The producer is the Steam Controller coil path; the renderer is the
|
||||
/// client-side coil write. Wire up either half and this becomes live with no format change.
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
@@ -460,7 +478,7 @@ impl HidOutput {
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
@@ -497,10 +515,17 @@ impl HidOutput {
|
||||
pad: b[2],
|
||||
bits: b[3],
|
||||
}),
|
||||
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
|
||||
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
|
||||
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
|
||||
// truncated datagram could therefore silently cancel the trigger a game was holding.
|
||||
// A genuine "no effect" is a full-length zero block and still decodes fine.
|
||||
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
|
||||
pad: b[2],
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
// Bounded like `HidRaw` below: at most the parameter block is kept from the
|
||||
// (attacker-sized) tail.
|
||||
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
@@ -981,6 +1006,82 @@ mod tests {
|
||||
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||
}
|
||||
|
||||
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
|
||||
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
|
||||
/// AND on the way in, and a body with no effect bytes must not decode at all.
|
||||
#[test]
|
||||
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
|
||||
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
|
||||
let long = HidOutput::Trigger {
|
||||
pad: 1,
|
||||
which: 0,
|
||||
effect: vec![0xAB; 200],
|
||||
};
|
||||
let d = long.encode();
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
4 + TRIGGER_EFFECT_MAX,
|
||||
"magic + kind + pad + which + at most the parameter block"
|
||||
);
|
||||
|
||||
// Decode clamps independently of encode — a hostile peer does not use our encoder.
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
|
||||
hostile.extend_from_slice(&[0xCD; 500]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::Trigger { effect, .. }) => {
|
||||
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
|
||||
}
|
||||
other => panic!("expected a clamped Trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// An exact-length effect survives untouched, and round-trips.
|
||||
let ok = HidOutput::Trigger {
|
||||
pad: 2,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
|
||||
}
|
||||
|
||||
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
|
||||
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
|
||||
/// releases whatever effect the game was holding. A truncated datagram must not do that.
|
||||
#[test]
|
||||
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
|
||||
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
|
||||
assert_eq!(HidOutput::decode(&empty), None);
|
||||
|
||||
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
|
||||
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
|
||||
assert_eq!(
|
||||
HidOutput::decode(&one),
|
||||
Some(HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![0x02]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
|
||||
/// cannot drift apart again.
|
||||
#[test]
|
||||
fn hid_raw_stays_bounded_on_both_sides() {
|
||||
let long = HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: HID_RAW_OUTPUT,
|
||||
data: vec![0x11; 500],
|
||||
};
|
||||
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
|
||||
|
||||
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
|
||||
hostile.extend_from_slice(&[0x22; 900]);
|
||||
match HidOutput::decode(&hostile) {
|
||||
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
|
||||
other => panic!("expected a clamped HidRaw, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
|
||||
// v2 envelope round-trips seq + ttl.
|
||||
|
||||
+172
-143
@@ -45,6 +45,10 @@
|
||||
// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
|
||||
// clock offset ongoing latency math must use; the connect-time getter stays frozen by
|
||||
// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
|
||||
// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
|
||||
// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
|
||||
// unchanged. (Documented late — the bump shipped without its line here.)
|
||||
// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
|
||||
// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
|
||||
// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
|
||||
@@ -58,7 +62,15 @@
|
||||
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
|
||||
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
|
||||
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 14
|
||||
// v15: versions the shared rumble policy engine's C surface —
|
||||
// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
|
||||
// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
|
||||
// still read 7 and no bump was made, so every core since has exported them while advertising a
|
||||
// version that never promised them. That cannot be corrected retroactively — a shipped binary
|
||||
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 15
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -66,7 +78,7 @@
|
||||
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
#define WIRE_VERSION 2
|
||||
#define PUNKTFUNK_WIRE_VERSION 2
|
||||
|
||||
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
#define PUNKTFUNK_HIDOUT_LED 1
|
||||
@@ -323,41 +335,41 @@
|
||||
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
|
||||
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
|
||||
// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
|
||||
#define LEGACY_STALE_MS 1000
|
||||
#define PUNKTFUNK_LEGACY_STALE_MS 1000
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
|
||||
// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
|
||||
// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
|
||||
#define CLIP_FETCH_CAP (64 << 20)
|
||||
#define PUNKTFUNK_CLIP_FETCH_CAP (64 << 20)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
|
||||
// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
|
||||
// then be routed to the right table.
|
||||
#define INBOUND_REQ_FLAG 2147483648
|
||||
#define PUNKTFUNK_INBOUND_REQ_FLAG 2147483648
|
||||
#endif
|
||||
|
||||
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
|
||||
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
|
||||
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
|
||||
// bottom out here instead of producing degenerate confetti-sized shards.
|
||||
#define MIN_SHARD_PAYLOAD 512
|
||||
#define PUNKTFUNK_MIN_SHARD_PAYLOAD 512
|
||||
|
||||
// 16-byte AEAD authentication tag appended by either session cipher.
|
||||
#define TAG_LEN 16
|
||||
#define PUNKTFUNK_TAG_LEN 16
|
||||
|
||||
// Wire tag distinguishing an input datagram from a video packet.
|
||||
#define INPUT_MAGIC 200
|
||||
#define PUNKTFUNK_INPUT_MAGIC 200
|
||||
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define MAX_PADS 16
|
||||
#define PUNKTFUNK_MAX_PADS 16
|
||||
|
||||
#define PUNKTFUNK_BTN_DPAD_UP 1
|
||||
|
||||
@@ -390,16 +402,16 @@
|
||||
#define PUNKTFUNK_BTN_Y 32768
|
||||
|
||||
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
#define BTN_PADDLE1 65536
|
||||
#define PUNKTFUNK_BTN_PADDLE1 65536
|
||||
|
||||
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
#define BTN_PADDLE2 131072
|
||||
#define PUNKTFUNK_BTN_PADDLE2 131072
|
||||
|
||||
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
#define BTN_PADDLE3 262144
|
||||
#define PUNKTFUNK_BTN_PADDLE3 262144
|
||||
|
||||
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
#define BTN_PADDLE4 524288
|
||||
#define PUNKTFUNK_BTN_PADDLE4 524288
|
||||
|
||||
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
@@ -407,7 +419,7 @@
|
||||
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
|
||||
|
||||
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
#define BTN_MISC1 2097152
|
||||
#define PUNKTFUNK_BTN_MISC1 2097152
|
||||
|
||||
// Axis ids for `InputKind::GamepadAxis`.
|
||||
#define PUNKTFUNK_AXIS_LS_X 0
|
||||
@@ -426,16 +438,16 @@
|
||||
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
|
||||
#define PUNKTFUNK_MAGIC 201
|
||||
|
||||
#define FLAG_PIC 1
|
||||
#define PUNKTFUNK_FLAG_PIC 1
|
||||
|
||||
#define FLAG_EOF 2
|
||||
#define PUNKTFUNK_FLAG_EOF 2
|
||||
|
||||
#define FLAG_SOF 4
|
||||
#define PUNKTFUNK_FLAG_SOF 4
|
||||
|
||||
// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
|
||||
// the host burst access units carrying this flag so the client measures throughput/loss without
|
||||
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||
#define FLAG_PROBE 8
|
||||
#define PUNKTFUNK_FLAG_PROBE 8
|
||||
|
||||
// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
|
||||
// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
|
||||
@@ -444,7 +456,7 @@
|
||||
// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
|
||||
// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
|
||||
// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
|
||||
#define USER_FLAG_RECOVERY_POINT 16
|
||||
#define PUNKTFUNK_USER_FLAG_RECOVERY_POINT 16
|
||||
|
||||
// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
|
||||
// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
|
||||
@@ -454,7 +466,7 @@
|
||||
// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
|
||||
// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
|
||||
// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
|
||||
#define USER_FLAG_RECOVERY_ANCHOR 32
|
||||
#define PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR 32
|
||||
|
||||
// `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every
|
||||
// `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the
|
||||
@@ -462,7 +474,7 @@
|
||||
// consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer
|
||||
// AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a
|
||||
// COMPLETE frame must be consumed window-by-window (the padding is not part of the stream).
|
||||
#define USER_FLAG_CHUNK_ALIGNED 64
|
||||
#define PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED 64
|
||||
|
||||
// `user_flags` bit: this AU was packetized as a **slice-streamed** frame (the P2 slice
|
||||
// pipeline): its sentinel blocks (`block_count == 0`) are SLICE-granularity and carry their
|
||||
@@ -475,7 +487,7 @@
|
||||
// [`VIDEO_CAP_STREAMED_AU`](crate::quic::VIDEO_CAP_STREAMED_AU) ∧
|
||||
// [`VIDEO_CAP_MULTI_SLICE`](crate::quic::VIDEO_CAP_MULTI_SLICE) — the pair whose receivers
|
||||
// know this contract.
|
||||
#define USER_FLAG_SLICE_STREAM 128
|
||||
#define PUNKTFUNK_USER_FLAG_SLICE_STREAM 128
|
||||
|
||||
// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
|
||||
// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
|
||||
@@ -484,7 +496,7 @@
|
||||
// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
|
||||
// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
|
||||
// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
|
||||
#define RFI_MAX_RANGE 256
|
||||
#define PUNKTFUNK_RFI_MAX_RANGE 256
|
||||
|
||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
@@ -498,22 +510,22 @@
|
||||
// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
// this is the acceptance ceiling, not a transmit size.
|
||||
#define MAX_DATAGRAM_BYTES 9216
|
||||
#define PUNKTFUNK_MAX_DATAGRAM_BYTES 9216
|
||||
|
||||
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
|
||||
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
|
||||
// once this much has accumulated (~22 KB at the standard shard payload). Small slices simply
|
||||
// ride with the next one; the wire is never worse than one flush per slice.
|
||||
#define MIN_STREAM_BLOCK_SHARDS 16
|
||||
#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
#define VIDEO_CAP_10BIT 1
|
||||
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
#define VIDEO_CAP_HDR 2
|
||||
#define PUNKTFUNK_VIDEO_CAP_HDR 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -525,7 +537,7 @@
|
||||
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
|
||||
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
|
||||
// where the hardware allows).
|
||||
#define VIDEO_CAP_444 4
|
||||
#define PUNKTFUNK_VIDEO_CAP_444 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -535,7 +547,7 @@
|
||||
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
|
||||
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
|
||||
// stage. Purely observability — never changes what the host encodes.
|
||||
#define VIDEO_CAP_HOST_TIMING 8
|
||||
#define PUNKTFUNK_VIDEO_CAP_HOST_TIMING 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -550,7 +562,7 @@
|
||||
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
||||
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||
// reassembler would silently drop as stale.
|
||||
#define VIDEO_CAP_PROBE_SEQ 16
|
||||
#define PUNKTFUNK_VIDEO_CAP_PROBE_SEQ 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -565,7 +577,7 @@
|
||||
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
|
||||
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
||||
// the fallback is zero-risk.
|
||||
#define VIDEO_CAP_STREAMED_AU 32
|
||||
#define PUNKTFUNK_VIDEO_CAP_STREAMED_AU 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -579,7 +591,7 @@
|
||||
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||
// control channel, so there is no downgrade surface.
|
||||
#define VIDEO_CAP_CHACHA20 64
|
||||
#define PUNKTFUNK_VIDEO_CAP_CHACHA20 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -595,7 +607,7 @@
|
||||
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
|
||||
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
|
||||
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
|
||||
#define VIDEO_CAP_MULTI_SLICE 128
|
||||
#define PUNKTFUNK_VIDEO_CAP_MULTI_SLICE 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -604,7 +616,7 @@
|
||||
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
#define HOST_CAP_GAMEPAD_STATE 1
|
||||
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -614,7 +626,7 @@
|
||||
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
// trailing `host_caps` byte — no wire-layout change.
|
||||
#define HOST_CAP_CLIPBOARD 2
|
||||
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -626,7 +638,7 @@
|
||||
// non-Latin scripts, emoji) through `TextInput` instead of lossy VK synthesis; absent the bit it
|
||||
// keeps the VK fallback. Packs into the existing trailing `host_caps` byte — no wire-layout
|
||||
// change; an older host ignores the unknown input tag anyway (input is lossy by design).
|
||||
#define HOST_CAP_TEXT_INPUT 4
|
||||
#define PUNKTFUNK_HOST_CAP_TEXT_INPUT 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -638,7 +650,7 @@
|
||||
// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
|
||||
// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
|
||||
// an older or incapable host nothing changes.
|
||||
#define CLIENT_CAP_CURSOR 1
|
||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -647,7 +659,7 @@
|
||||
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
|
||||
// the bit the host never arms the phase controller; toward an older host the reports are
|
||||
// simply ignored — no behavior change in either direction.
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -660,7 +672,7 @@
|
||||
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
|
||||
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
#define CLIENT_CAP_AUDIO_RED 4
|
||||
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -671,7 +683,7 @@
|
||||
// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
|
||||
// [`CursorState`](super::datagram::CursorState) instead. `0x08` — `0x04` is
|
||||
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
#define HOST_CAP_CURSOR 8
|
||||
#define PUNKTFUNK_HOST_CAP_CURSOR 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -685,7 +697,7 @@
|
||||
// wire ships ahead of the backend (P0): no host sets this bit until the P1 injector lands —
|
||||
// which is exactly why the gate exists. `0x10` — `0x08` is [`HOST_CAP_CURSOR`], `0x04` is
|
||||
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
#define HOST_CAP_PEN 16
|
||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -699,25 +711,25 @@
|
||||
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
|
||||
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
#define HOST_CAP_AUDIO_RED 32
|
||||
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
// advertise this.
|
||||
#define CODEC_H264 1
|
||||
#define PUNKTFUNK_CODEC_H264 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
|
||||
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
|
||||
#define CODEC_HEVC 2
|
||||
#define PUNKTFUNK_CODEC_HEVC 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode AV1.
|
||||
#define CODEC_AV1 4
|
||||
#define PUNKTFUNK_CODEC_AV1 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -731,18 +743,18 @@
|
||||
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
|
||||
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
|
||||
// version instead (plan §4.2).
|
||||
#define CODEC_PYROWAVE 8
|
||||
#define PUNKTFUNK_CODEC_PYROWAVE 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||
// default when a peer omits [`Welcome::chroma_format`].
|
||||
#define CHROMA_IDC_420 1
|
||||
#define PUNKTFUNK_CHROMA_IDC_420 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||
#define CHROMA_IDC_444 3
|
||||
#define PUNKTFUNK_CHROMA_IDC_444 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -793,195 +805,195 @@
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||
#define MSG_RECONFIGURE 1
|
||||
#define PUNKTFUNK_MSG_RECONFIGURE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigured`].
|
||||
#define MSG_RECONFIGURED 2
|
||||
#define PUNKTFUNK_MSG_RECONFIGURED 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`RequestKeyframe`].
|
||||
#define MSG_REQUEST_KEYFRAME 3
|
||||
#define PUNKTFUNK_MSG_REQUEST_KEYFRAME 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`LossReport`].
|
||||
#define MSG_LOSS_REPORT 4
|
||||
#define PUNKTFUNK_MSG_LOSS_REPORT 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`SetBitrate`].
|
||||
#define MSG_SET_BITRATE 5
|
||||
#define PUNKTFUNK_MSG_SET_BITRATE 5
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`BitrateChanged`].
|
||||
#define MSG_BITRATE_CHANGED 6
|
||||
#define PUNKTFUNK_MSG_BITRATE_CHANGED 6
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`RfiRequest`].
|
||||
#define MSG_RFI_REQUEST 7
|
||||
#define PUNKTFUNK_MSG_RFI_REQUEST 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadChanged`].
|
||||
#define MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadAck`].
|
||||
#define MSG_SHARD_PAYLOAD_ACK 9
|
||||
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define MSG_PROBE_REQUEST 32
|
||||
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeResult`].
|
||||
#define MSG_PROBE_RESULT 33
|
||||
#define PUNKTFUNK_MSG_PROBE_RESULT 33
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClockProbe`].
|
||||
#define MSG_CLOCK_PROBE 48
|
||||
#define PUNKTFUNK_MSG_CLOCK_PROBE 48
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClockEcho`].
|
||||
#define MSG_CLOCK_ECHO 49
|
||||
#define PUNKTFUNK_MSG_CLOCK_ECHO 49
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PhaseReport`].
|
||||
#define MSG_PHASE_REPORT 50
|
||||
#define PUNKTFUNK_MSG_PHASE_REPORT 50
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
#define MSG_CLIP_CONTROL 64
|
||||
#define PUNKTFUNK_MSG_CLIP_CONTROL 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
#define MSG_CLIP_STATE 65
|
||||
#define PUNKTFUNK_MSG_CLIP_STATE 65
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
#define MSG_CLIP_OFFER 66
|
||||
#define PUNKTFUNK_MSG_CLIP_OFFER 66
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
// current offer.
|
||||
#define MSG_CLIP_FETCH 67
|
||||
#define PUNKTFUNK_MSG_CLIP_FETCH 67
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
// header that precedes the data chunks.
|
||||
#define MSG_CLIP_FETCH_HDR 68
|
||||
#define PUNKTFUNK_MSG_CLIP_FETCH_HDR 68
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
#define CLIP_FLAG_FILES 1
|
||||
#define PUNKTFUNK_CLIP_FLAG_FILES 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
// while enabled unless a future direction limit clears it.
|
||||
#define CLIP_POLICY_TEXT 1
|
||||
#define PUNKTFUNK_CLIP_POLICY_TEXT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
// / `text-only` policy so the client can grey out "Include files".
|
||||
#define CLIP_POLICY_FILES 2
|
||||
#define PUNKTFUNK_CLIP_POLICY_FILES 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
#define CLIP_REASON_OK 0
|
||||
#define PUNKTFUNK_CLIP_REASON_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
// session with no data-control global) — the client shows "not supported in this session type".
|
||||
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#define PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
// one was disabled (last `ClipControl{enabled}` wins).
|
||||
#define CLIP_REASON_TAKEN_OVER 2
|
||||
#define PUNKTFUNK_CLIP_REASON_TAKEN_OVER 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
#define CLIP_REASON_POLICY_DISABLED 3
|
||||
#define PUNKTFUNK_CLIP_REASON_POLICY_DISABLED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
#define CLIP_REASON_NO_FILES 4
|
||||
#define PUNKTFUNK_CLIP_REASON_NO_FILES 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
#define CLIP_FETCH_OK 0
|
||||
#define PUNKTFUNK_CLIP_FETCH_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
#define CLIP_FETCH_STALE 1
|
||||
#define PUNKTFUNK_CLIP_FETCH_STALE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_UNAVAILABLE 2
|
||||
#define PUNKTFUNK_CLIP_FETCH_UNAVAILABLE 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_DENIED 3
|
||||
#define PUNKTFUNK_CLIP_FETCH_DENIED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
#define CLIP_MAX_KINDS 16
|
||||
#define PUNKTFUNK_CLIP_MAX_KINDS 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
#define CLIP_MAX_MIME 128
|
||||
#define PUNKTFUNK_CLIP_MAX_MIME 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
// file *manifest* itself). Real file fetches use `0..n`.
|
||||
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#define PUNKTFUNK_CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
|
||||
#define MSG_CURSOR_SHAPE 80
|
||||
#define PUNKTFUNK_MSG_CURSOR_SHAPE 80
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`CursorRenderMode`] (client → host): who renders the pointer right now.
|
||||
#define MSG_CURSOR_RENDER 81
|
||||
#define PUNKTFUNK_MSG_CURSOR_RENDER 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -990,7 +1002,7 @@
|
||||
// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
|
||||
// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
|
||||
// larger before forwarding, so the cap is invisible to clients.
|
||||
#define CURSOR_SHAPE_MAX_SIDE 120
|
||||
#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1010,21 +1022,21 @@
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
|
||||
#define MIC_MAGIC 203
|
||||
#define PUNKTFUNK_MIC_MAGIC 203
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
|
||||
// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
|
||||
// kind-tagged (see [`RichInput`]).
|
||||
#define RICH_INPUT_MAGIC 204
|
||||
#define PUNKTFUNK_RICH_INPUT_MAGIC 204
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
|
||||
// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
|
||||
// [`HidOutput`].
|
||||
#define HIDOUT_MAGIC 205
|
||||
#define PUNKTFUNK_HIDOUT_MAGIC 205
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1064,7 +1076,7 @@
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of a v1 (legacy, level) rumble datagram.
|
||||
#define RUMBLE_V1_LEN 7
|
||||
#define PUNKTFUNK_RUMBLE_V1_LEN 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1072,48 +1084,60 @@
|
||||
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
|
||||
// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
|
||||
// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
|
||||
#define RUMBLE_V2_LEN 10
|
||||
#define PUNKTFUNK_RUMBLE_V2_LEN 10
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
|
||||
// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
|
||||
// 46–54 bytes; feature and output reports are at most 64).
|
||||
#define HID_REPORT_MAX 64
|
||||
#define PUNKTFUNK_HID_REPORT_MAX 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
|
||||
// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
|
||||
// into its report.
|
||||
//
|
||||
// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
|
||||
// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
|
||||
// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
|
||||
// been bounded on both ends all along.
|
||||
#define PUNKTFUNK_TRIGGER_EFFECT_MAX 11
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
// it on the physical device's interrupt-OUT endpoint / GATT write.
|
||||
#define HID_RAW_OUTPUT 0
|
||||
#define PUNKTFUNK_HID_RAW_OUTPUT 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
|
||||
// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
|
||||
// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
|
||||
#define HID_RAW_FEATURE 1
|
||||
#define PUNKTFUNK_HID_RAW_FEATURE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||
#define HDR_META_MAGIC 206
|
||||
#define PUNKTFUNK_HDR_META_MAGIC 206
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
|
||||
// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
|
||||
// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body).
|
||||
#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
|
||||
#define PUNKTFUNK_HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
|
||||
// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
|
||||
// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
|
||||
#define HOST_TIMING_MAGIC 207
|
||||
#define PUNKTFUNK_HOST_TIMING_MAGIC 207
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1124,18 +1148,18 @@
|
||||
// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
|
||||
// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
|
||||
// datagram only moves/hides the pointer.
|
||||
#define CURSOR_STATE_MAGIC 208
|
||||
#define PUNKTFUNK_CURSOR_STATE_MAGIC 208
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`CursorState::flags`] bit: the host cursor is visible.
|
||||
#define CURSOR_VISIBLE 1
|
||||
#define PUNKTFUNK_CURSOR_VISIBLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
|
||||
// relative/captured (M3 auto-flip; advisory, user override always wins).
|
||||
#define CURSOR_RELATIVE_HINT 2
|
||||
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1144,7 +1168,7 @@
|
||||
// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
|
||||
// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
|
||||
// reconnect can resume. Shared so host + every client agree on the code.
|
||||
#define QUIT_CLOSE_CODE 81
|
||||
#define PUNKTFUNK_QUIT_CLOSE_CODE 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1154,107 +1178,107 @@
|
||||
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
|
||||
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
|
||||
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
#define APP_EXITED_CLOSE_CODE 82
|
||||
#define PUNKTFUNK_APP_EXITED_CLOSE_CODE 82
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||
#define HELLO_NAME_MAX 64
|
||||
#define PUNKTFUNK_HELLO_NAME_MAX 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short
|
||||
// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
|
||||
#define HELLO_LAUNCH_MAX 128
|
||||
#define PUNKTFUNK_HELLO_LAUNCH_MAX 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||
// only one pre-cipher builds know).
|
||||
#define CIPHER_AES_128_GCM 0
|
||||
#define PUNKTFUNK_CIPHER_AES_128_GCM 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||
#define CIPHER_CHACHA20_POLY1305 1
|
||||
#define PUNKTFUNK_CIPHER_CHACHA20_POLY1305 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairRequest`].
|
||||
#define MSG_PAIR_REQUEST 16
|
||||
#define PUNKTFUNK_MSG_PAIR_REQUEST 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairChallenge`].
|
||||
#define MSG_PAIR_CHALLENGE 17
|
||||
#define PUNKTFUNK_MSG_PAIR_CHALLENGE 17
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairProof`].
|
||||
#define MSG_PAIR_PROOF 18
|
||||
#define PUNKTFUNK_MSG_PAIR_PROOF 18
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairResult`].
|
||||
#define MSG_PAIR_RESULT 19
|
||||
#define PUNKTFUNK_MSG_PAIR_RESULT 19
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by
|
||||
// [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a
|
||||
// coherent contact).
|
||||
#define PEN_IN_RANGE 1
|
||||
#define PUNKTFUNK_PEN_IN_RANGE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the tip is in contact with the surface.
|
||||
#define PEN_TOUCHING 2
|
||||
#define PUNKTFUNK_PEN_TOUCHING 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held.
|
||||
#define PEN_BARREL1 4
|
||||
#define PUNKTFUNK_PEN_BARREL1 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping)
|
||||
// is held.
|
||||
#define PEN_BARREL2 8
|
||||
#define PUNKTFUNK_PEN_BARREL2 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1;
|
||||
// receivers MUST ignore samples carrying it until a capability negotiates otherwise
|
||||
// (design/pen-tablet-input.md §8).
|
||||
#define PEN_PREDICTED 128
|
||||
#define PUNKTFUNK_PEN_PREDICTED 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading.
|
||||
#define PEN_TILT_UNKNOWN 255
|
||||
#define PUNKTFUNK_PEN_TILT_UNKNOWN 255
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading.
|
||||
#define PEN_ANGLE_UNKNOWN 65535
|
||||
#define PUNKTFUNK_PEN_ANGLE_UNKNOWN 65535
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PenSample::distance`] sentinel: no hover-distance reading.
|
||||
#define PEN_DISTANCE_UNKNOWN 65535
|
||||
#define PUNKTFUNK_PEN_DISTANCE_UNKNOWN 65535
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence
|
||||
// (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches.
|
||||
#define PEN_BATCH_MAX 8
|
||||
#define PUNKTFUNK_PEN_BATCH_MAX 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of one encoded [`PenSample`].
|
||||
#define PEN_SAMPLE_WIRE_LEN 21
|
||||
#define PUNKTFUNK_PEN_SAMPLE_WIRE_LEN 21
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1265,13 +1289,13 @@
|
||||
// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while
|
||||
// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live
|
||||
// stationary stroke two heartbeats clear of the deadline.
|
||||
#define PEN_TOUCH_TIMEOUT_MS 200
|
||||
#define PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS 200
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||
#define CLIP_STREAM_KIND_FETCH 1
|
||||
#define PUNKTFUNK_CLIP_STREAM_KIND_FETCH 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
@@ -1281,18 +1305,18 @@
|
||||
// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
|
||||
// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
|
||||
// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
|
||||
#define CLIP_CANCELLED_CODE 112
|
||||
#define PUNKTFUNK_CLIP_CANCELLED_CODE 112
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
#define CLIP_CHUNK (64 * 1024)
|
||||
#define PUNKTFUNK_CLIP_CHUNK (64 * 1024)
|
||||
#endif
|
||||
|
||||
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||
// almost immediately instead of never.
|
||||
#define NO_OUTPUT_KEYFRAME_STREAK 3
|
||||
#define PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK 3
|
||||
|
||||
// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
|
||||
// latest loss before the gate lifts its freeze on an IDR-free stream. TWO, not one: with a continuous
|
||||
@@ -1304,12 +1328,12 @@
|
||||
// deliberate "hold longer, never show garbage" trade.
|
||||
//
|
||||
// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
#define REANCHOR_MARKS_TO_LIFT 2
|
||||
#define PUNKTFUNK_REANCHOR_MARKS_TO_LIFT 2
|
||||
|
||||
// QUIC application error code the host closes with on a `mode_conflict = reject` admission
|
||||
// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code
|
||||
// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it.
|
||||
#define REJECT_BUSY_CLOSE_CODE 66
|
||||
#define PUNKTFUNK_REJECT_BUSY_CLOSE_CODE 66
|
||||
|
||||
// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can
|
||||
// tell the user WHY it was turned away instead of collapsing every close into a generic
|
||||
@@ -1318,44 +1342,44 @@
|
||||
// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end
|
||||
// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the
|
||||
// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`].
|
||||
#define PAIR_NOT_ARMED_CLOSE_CODE 96
|
||||
#define PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE 96
|
||||
|
||||
// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not
|
||||
// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract.
|
||||
#define PAIR_BOUND_OTHER_CLOSE_CODE 97
|
||||
#define PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE 97
|
||||
|
||||
// PIN attempt inside the host's global pairing cooldown — retry shortly.
|
||||
#define PAIR_RATE_LIMITED_CLOSE_CODE 98
|
||||
#define PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE 98
|
||||
|
||||
// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an
|
||||
// identity to bind — the PIN flow with a client identity is the way in.
|
||||
#define PAIR_NO_IDENTITY_CLOSE_CODE 99
|
||||
#define PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE 99
|
||||
|
||||
// The operator explicitly denied this pairing request in the host console.
|
||||
#define PAIR_DENIED_CLOSE_CODE 100
|
||||
#define PUNKTFUNK_PAIR_DENIED_CLOSE_CODE 100
|
||||
|
||||
// Nobody decided on the parked pairing request before the host's approval wait elapsed.
|
||||
#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
|
||||
#define PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
|
||||
|
||||
// This parked knock was superseded by a newer connection from the same device — only the
|
||||
// newest is admitted on approval.
|
||||
#define PAIR_SUPERSEDED_CLOSE_CODE 102
|
||||
#define PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE 102
|
||||
|
||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||
#define WIRE_VERSION_CLOSE_CODE 103
|
||||
#define PUNKTFUNK_WIRE_VERSION_CLOSE_CODE 103
|
||||
|
||||
// The host admitted the connection but could not stand the stream session up (compositor /
|
||||
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||
// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||
// finished mid-frame") — indistinguishable from transport trouble.
|
||||
#define SETUP_FAILED_CLOSE_CODE 104
|
||||
#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104
|
||||
|
||||
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||
#define MIN_SCALE 0.5
|
||||
#define PUNKTFUNK_MIN_SCALE 0.5
|
||||
|
||||
// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
|
||||
#define MAX_SCALE 4.0
|
||||
#define PUNKTFUNK_MAX_SCALE 4.0
|
||||
|
||||
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
||||
// test `rc < 0`. Do not renumber existing variants — only append.
|
||||
@@ -1649,7 +1673,10 @@ typedef struct {
|
||||
// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
|
||||
uint8_t effect_len;
|
||||
// Trigger: the raw DualSense trigger parameter block (mode + params).
|
||||
uint8_t effect[11];
|
||||
// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
|
||||
// exported precisely so embedders can size their own buffers against it, and it declaring one
|
||||
// number while the struct it describes hardcoded another was the whole hazard.
|
||||
uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX];
|
||||
} PunktfunkHidOutput;
|
||||
#endif
|
||||
|
||||
@@ -1901,7 +1928,7 @@ typedef struct {
|
||||
|
||||
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
|
||||
// users reason about. Shared so every client's list stays identical.
|
||||
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||
#define PUNKTFUNK_PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -2445,10 +2472,12 @@ PunktfunkStatus punktfunk_connection_next_rumble_cmd(PunktfunkConnection *c,
|
||||
// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||
// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||
// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||
// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||
// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||
// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
|
||||
// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
|
||||
// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
|
||||
// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||
// actuator.
|
||||
// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
|
||||
// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle. Callable from any thread.
|
||||
|
||||
Reference in New Issue
Block a user