Compare commits

...
Author SHA1 Message Date
enricobuehler 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 2f071a9a93 Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:08:15 +00:00
enricobuehler 62d35bc4b6 Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:07:54 +00:00
enricobuehler 5d06ef26ac Merge pull request 'fix(feedback): the pad stops keeping a game's trigger effect after the stream ends' (#44) from worktree-haptics-m9-richfb into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:07:35 +00:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. 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.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 77ddd05b13 fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults.

An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. 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, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.

The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
34 changed files with 1427 additions and 405 deletions
@@ -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
}
}
+86 -6
View File
@@ -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 aliasbit 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)
+13 -3
View File
@@ -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();
+153 -6
View File
@@ -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::*;
+5 -21
View File
@@ -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 {
+7 -7
View File
@@ -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(),
});
}
}
+6 -1
View File
@@ -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
+5
View File
@@ -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).
+161
View File
@@ -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]
+9 -4
View File
@@ -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,
+18 -3
View File
@@ -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.
+13 -1
View File
@@ -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**
+104 -3
View File
@@ -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
View File
@@ -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
// 4654 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.