Merge remote-tracking branch 'origin/main' into audio/latency-overhaul

This commit is contained in:
2026-08-08 00:16:02 +02:00
93 changed files with 4799 additions and 527 deletions
@@ -850,7 +850,8 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
field = "gamepad",
enabled = s.gamepadForwarding,
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
"every connected one is forwarded as its own player. An X-Box type has no " +
"gyroscope, so pick a DualSense-class one if you want motion.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
@@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.PadSensors
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.VideoDecoders
@@ -138,6 +139,19 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
micHint = null
}
}
// A captured pad has a gyro this session's virtual controller cannot carry (see
// GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise
// completely silent — the gyro simply does nothing, which from the couch is indistinguishable
// from a broken sensor — and the fix is a setting, so the notice has to name it.
var motionHint by remember { mutableStateOf(false) }
LaunchedEffect(motionHint) {
if (motionHint) {
// Longer than the mic chord's 1.6 s: that one confirms something the user just did,
// this one explains something they did not, in a sentence they have to read.
delay(6000)
motionHint = false
}
}
// The one place mute is toggled — Compose state + the native flag, always together.
val setMicMuted = { muted: Boolean ->
micMuted = muted
@@ -360,6 +374,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
// to mute, and claiming otherwise would be the lie the control exists to avoid).
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
// moment it is claimed, on the main thread.
router.onMotionUnreachable = { motionHint = true }
router.onMicChord = {
if (micRunning) {
val next = !micMuted
@@ -459,16 +476,36 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
// down whenever a capture link — USB DualSense / SC2, pads with a real IMU — holds
// pad 0), so it composes with the captures below without coordination here.
// down whenever pad 0's controller has motion of its own — a capture link below, or a
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
DeviceGyro(context, handle, router).also { it.start() }
} else {
null
}
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
// capture links below ever sees it and its motion used to go nowhere at all. No separate
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
// captures it claims nothing; forwarding being off is the only thing that silences it.
val padSensors = if (initialSettings.gamepadForwarding) {
PadSensors(router).also { it.start() }
} else {
null
}
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved
// session leaks until the session ends), and take its sensor listeners off with it — the
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
// the binds. Assigned before the captures are constructed, so their claims land on it.
router.onSlotClosed = { deviceId ->
feedback.onDeviceRemoved(deviceId)
padSensors?.onSlotClosed(deviceId)
}
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
// read too. The pads already connected were swept by PadSensors.start() above — both run
// on the main thread with nothing between them, so no controller falls through the gap.
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
// already-paired BLE one — and forward its raw reports; the host mirrors a real
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
@@ -599,12 +636,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
// sensors let go of it; before the router is released, so the parks still find slots.
padSensors?.stop()
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.onMicChord = null // same: no mute toggle on buttons released during teardown
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
@@ -865,6 +906,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
// notice landing on top of one of those would cost the user both.
if (motionHint) {
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
}
}
}
@@ -951,6 +997,28 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
)
}
/**
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
* HID contract, so every sample would be decoded and dropped host-side).
*
* It names the setting because that is the whole point: without it the player has a gyro that
* silently does nothing and no way to tell that from a broken sensor. Not a control — the setting
* applies from the next session, so offering to change it here would promise something this stream
* cannot deliver. [GamepadRouter.onMotionUnreachable] raises it.
*/
@Composable
private fun MotionUnreachableHint(modifier: Modifier = Modifier) {
Text(
"Motion won't reach this session — set Controller type to DualSense",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
@@ -11,7 +11,6 @@ import android.os.HandlerThread
import android.view.Display
import android.view.Surface
import android.view.WindowManager
import kotlin.math.roundToInt
/**
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
@@ -33,10 +32,10 @@ import kotlin.math.roundToInt
* host's virtual pad never keeps integrating an angular velocity this device stopped
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
*
* Units are the wire contract (mirrors `pf-client-core`'s constants): gyro rad/s → 20 LSB/°·s,
* accel m/s² → g → 10000 LSB/g. Android's accelerometer reads specific force (+1 g on the up
* axis at rest), which is the DualSense report's own convention — no sign flip. The one thing
* the phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
* Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] —
* the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected
* once for every sender rather than once per sender that someone remembers. The one thing the
* phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
@@ -64,7 +63,7 @@ class DeviceGyro(
private val thread = HandlerThread("pf-phone-gyro")
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
private val lastAccel = intArrayOf(0, ACCEL_LSB_PER_G, 0)
private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
private var wasWriting = false
@@ -103,10 +102,7 @@ class DeviceGyro(
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
for (i in 0..2) {
lastAccel[i] = (v[i] / GRAVITY * ACCEL_LSB_PER_G)
.roundToInt().coerceIn(-32768, 32767)
}
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
}
Sensor.TYPE_GYROSCOPE -> {
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
@@ -124,9 +120,9 @@ class DeviceGyro(
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
NativeBridge.nativeSendPadMotion(
handle, 0,
(v[0] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
(v[1] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
(v[2] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
Gamepad.motionGyroWire(v[0]),
Gamepad.motionGyroWire(v[1]),
Gamepad.motionGyroWire(v[2]),
lastAccel[0], lastAccel[1], lastAccel[2],
)
}
@@ -149,17 +145,12 @@ class DeviceGyro(
context.getSystemService(SensorManager::class.java)
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
/** ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz). */
private const val SAMPLING_PERIOD_US = 5000
/** The wire contract (pf-client-core `GYRO_LSB_PER_RAD_S`): 20 LSB/°·s from rad/s. */
const val GYRO_LSB_PER_RAD_S = 20f * 180f / Math.PI.toFloat()
/** The wire contract (pf-client-core `ACCEL_LSB_PER_G`). */
const val ACCEL_LSB_PER_G = 10_000
/** pf-client-core's `G`. */
const val GRAVITY = 9.80665f
/**
* ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also
* the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+),
* so asking for more would only be silently capped. Shared with [PadSensors].
*/
internal const val SAMPLING_PERIOD_US = 5000
/**
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
@@ -22,9 +22,11 @@ import android.view.InputDevice
*
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
* first parsed report as the fallback for a claim that found no free index, and freed on
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled
* into the wire's units by this pad's own calibration — read once per claim, off the claiming
* thread, with the nominal scaling standing in for the millisecond that read is in flight rather
* than the UI waiting on a control transfer). The wire slot is claimed when the capture engages,
* with the first parsed report as the fallback for a claim that found no free index, and freed on
* unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
@@ -55,6 +57,13 @@ class DsCapture(
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
/** This pad's factory motion scale, read once per capture on [calReader] and handed to the
* link thread, which scales nominally until it lands — see [MotionCalHandoff]. */
private val motionCal = MotionCalHandoff()
/** The thread doing the claim-time calibration read, kept for the teardown wait. */
@Volatile private var calReader: Thread? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
@@ -124,6 +133,11 @@ class DsCapture(
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
// Before `model`, which is what lets the link thread into the parse at all: opening the
// claim forgets the last pad's calibration, so reports arriving while this pad's own read
// (below, off this thread) is in flight fall back to the nominal scaling rather than to
// another unit's factory numbers.
val claim = motionCal.begin()
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
@@ -135,9 +149,88 @@ class DsCapture(
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
readMotionCalAsync(m, claim)
return true
}
/**
* Start this claim's calibration read, on its own thread.
*
* Off the caller's thread because [startUsb] runs on the main one — stream setup, and the
* USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is
* there answers in about a millisecond, but one that is stalling takes the link's whole write
* timeout, and the interface must wait for neither. The pad is live throughout, its motion
* nominally scaled until this lands ([onReport]), so even a pad that never answers costs
* precision rather than the UI or the controller.
*
* One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is
* awaited by [awaitCalRead] before the connection it reads from can be closed.
*/
private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) {
val t = Thread({
// A read that throws would otherwise leave the capture on the nominal scaling with
// nothing in the log to say why — the one outcome that looks identical to a pad whose
// calibration is genuinely nominal. Publish the fallback explicitly, and say so.
val cal = runCatching { readMotionCal(m) }.getOrElse {
Log.w(TAG, "motion calibration read failed — nominal scaling", it)
DsDevice.MotionCal.NOMINAL
}
// Discarded when the claim is already over (unplug, stop, or a re-claim beat us here):
// scaling the NEXT pad by this one's factory numbers would be worse than not reading.
if (!motionCal.publish(claim, cal)) {
Log.i(TAG, "motion calibration arrived after the claim ended — discarded")
}
}, "pf-ds-cal")
calReader = t
t.isDaemon = true
t.start()
}
/**
* Wait for an in-flight calibration read to let go of the USB connection, before a teardown
* closes it.
*
* Not politeness: the read is a control transfer on the very connection [HidUsbLink.stop] is
* about to close, and closing a descriptor with a transfer in flight pulls it out from under
* the kernel — the same rule the pad-audio borrow follows. Bounded, and in every case but a
* pad that has stopped answering the thread is long gone, so this returns immediately. It can
* never deadlock: the reading thread waits on nothing this one holds ([MotionCalHandoff] has
* its own monitor, and the read itself takes no lock).
*/
private fun awaitCalRead() {
val t = calReader ?: return
calReader = null
if (!t.isAlive) return
runCatching { t.join(CAL_JOIN_MS) }
if (t.isAlive) Log.w(TAG, "calibration read still in flight at teardown")
}
/**
* Read this pad's IMU calibration — the feature report that says how many raw counts this
* individual unit puts on a °/s and on a g ([DsDevice.MotionCal]).
*
* Once, at claim time, and nowhere else: the calibration is fixed for the life of the
* connection, so doing it per input report would buy nothing and cost the capture its latency.
* A pad that refuses keeps the nominal scaling rather than losing motion altogether.
*/
private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal {
val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen)
val cal = DsDevice.MotionCal.parse(blob, m.calReportId)
// Worth a line either way: this is the number the owed on-glass check reads back — a pad
// whose blob was read declares its own resolution, the fallback declares the wire's.
if (cal === DsDevice.MotionCal.NOMINAL) {
Log.w(
TAG,
"motion calibration 0x%02x unreadable (%d/%d B) — nominal scaling (%s)".format(
m.calReportId, blob?.size ?: 0, m.calReportLen, cal,
),
)
} else {
Log.i(TAG, "motion calibration 0x%02x: %s".format(m.calReportId, cal))
}
return cal
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
// Before anything touches the link: the pad-audio renderer borrows this connection's
@@ -157,6 +250,10 @@ class DsCapture(
resetRichFeedback(m)
}
disarmBackstop()
// End the claim before waiting on it: a calibration that lands after this publishes
// nothing, and then the wait makes sure nothing is still reading the connection below.
motionCal.end()
awaitCalRead()
usb.stop()
val wasActive = model != null
model = null
@@ -168,7 +265,10 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
// Nominal scaling until this claim's calibration read lands (see MotionCalHandoff): for
// that millisecond the pad behaves as it did before the read existed, which nobody can
// feel — unlike a pad whose buttons wait on a control transfer.
if (!DsDevice.parseState(m, report, len, state, motionCal.effective)) return
// Normally claimed already, at capture time; this is the retry for a capture that engaged
// while every wire index was taken.
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
@@ -189,7 +289,9 @@ class DsCapture(
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
val p = router.openExternal(m.pref) ?: return null
// hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out
// on the rich plane — so a session that cannot carry it is worth saying out loud.
val p = router.openExternal(m.pref, hasGyro = true) ?: return null
pad = p
Log.i(TAG, "captured $m → wire pad ${p.index}")
// The wire index exists from here on, and the host addresses pad audio by it.
@@ -279,6 +381,10 @@ class DsCapture(
val wasActive = model != null
model = null
releaseSlot()
// As in stop(): end the claim so a late calibration publishes nothing, then wait for the
// read to let go of the connection the line below closes.
motionCal.end()
awaitCalRead()
// Release the transport too: the link only *signals* the drop, so without this an unplug
// left its connection open, its interfaces claimed and its detach receiver registered.
usb.stop()
@@ -310,8 +416,8 @@ class DsCapture(
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
* on change per slot; motion forwarded every report (already in wire units — the parse applies
* this pad's calibration, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
@@ -483,5 +589,9 @@ class DsCapture(
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
* and the host has already moved on, so nothing else is coming to silence them. */
const val STOP_RETRY_MS = 100L
/** Teardown's budget for an in-flight calibration read. Comfortably past the link's own
* EP0 timeout, so it only ever elapses for a pad that has stopped answering entirely. */
const val CAL_JOIN_MS = 500L
}
}
@@ -1,5 +1,7 @@
package io.unom.punktfunk.kit
import kotlin.math.abs
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
@@ -28,14 +30,168 @@ object DsDevice {
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
* onto the wire's 0..65535 space, and the IMU-calibration feature report it answers
* ([MotionCal]): DS5/Edge `0x05` (id + 40 B), DS4 over USB `0x02` (id + 36 B).
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
enum class Model(
val pref: Int,
val outputSize: Int,
val touchW: Int,
val touchH: Int,
val calReportId: Int,
val calReportLen: Int,
) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080, 0x05, 41),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080, 0x05, 41),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942, 0x02, 37),
}
/**
* One pad's own IMU calibration: the factory scale factors that turn its raw motion counts
* into the wire's fixed units (`punktfunk_core::input::gamepad` — 20 LSB per °/s, 10000 LSB
* per g), read out of the calibration feature report the pad serves on EP0.
*
* **Why the pad's blob and not a constant.** Measured on glass 2026-08-07: a DualSense flat
* and face up arrived as 0.811 g where 1.000 was owed, because this path forwarded the raw
* i16s verbatim. The nominal ×10000/8192 rescale that first closed that gap ([NOMINAL]) still
* leaves that unit's factory bias — about 1 % — on acceleration, and provably cannot fix gyro
* at all: the same still-average showed this pad's gyro calibration is nowhere near identity,
* and a near-identity one would mean 1024 LSB per °/s, i.e. ±32 °/s full scale, which no
* controller has. The scale is per unit; only the pad knows it.
*
* The arithmetic is `hid-playstation`'s, and the host's contract test
* (`crates/pf-inject/tests/motion_contract.rs`, `SonyImuCalibration`) is the same math read
* from the other end — it applies it to the blobs our *virtual* pads declare and asserts they
* land on the wire constants. Per axis: gyro `raw × speed_2x × 20 / (|plus bias| +
* |minus bias|)`, accel `(raw (plus range/2)) × 20000 / range`, where `range = plus
* minus` spans 2 g.
*/
class MotionCal private constructor(
/** Per axis: `speed_2x × 20`, over `|plus bias| + |minus bias|`. */
private val gyroNumer: LongArray,
private val gyroDenom: LongArray,
/** Per axis: the raw count the pad reads at 0 g, and the raw span of 2 g. */
private val accelBias: LongArray,
private val accelRange: LongArray,
) {
/** Raw gyro count on [axis] (0 = pitch, 1 = yaw, 2 = roll) → the wire's 20 LSB per °/s. */
fun gyroToWire(axis: Int, raw: Int): Int =
clampWire(raw.toLong() * gyroNumer[axis] / gyroDenom[axis])
/** Raw acceleration count on [axis] → the wire's 10000 LSB per g, zero point removed. */
fun accelToWire(axis: Int, raw: Int): Int =
clampWire((raw - accelBias[axis]) * ACCEL_NUMER / accelRange[axis])
/**
* The derived resolutions, for the capture's one-line claim log — the number that says
* whether a pad's blob was actually read (a real DualSense declares ≈16 LSB/°·s and ≈8192
* LSB/g; the [NOMINAL] fallback reads back as exactly 20 and 8192).
*/
override fun toString(): String = buildString {
append("gyro ")
for (i in 0 until 3) {
if (i > 0) append('/')
append(gyroDenom[i] * WIRE_GYRO_LSB_PER_DEG_S / gyroNumer[i])
}
append(" LSB/°·s, accel ")
for (i in 0 until 3) {
if (i > 0) append('/')
append(accelRange[i] / 2)
}
append(" LSB/g at ")
append(accelBias.joinToString("/"))
}
/**
* Both conversions are a >1 multiplier on every pad measured so far, so a real ±4 g slam
* or a fast flick near full scale would otherwise wrap the i16 and read as an impossible
* motion in the opposite direction.
*/
private fun clampWire(v: Long): Int = v.coerceIn(-32768L, 32767L).toInt()
companion object {
/** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */
private const val RAW_ACCEL_LSB_PER_G = 8192L
/**
* The wire's gyro scale, taken from [Gamepad] rather than restated. These were literal
* `20L` / `10000L` until the sensor path hoisted the same numbers into one place; a
* second copy of a unit constant is precisely the defect this whole program opened
* with, and two of them in one module would be worse than the original.
*
* `val`, not `const val`, only because the widening to Long is not a compile-time
* constant expression. Long here on purpose: the arithmetic below multiplies raw counts
* by the calibration's speed term before dividing, which overflows an Int.
*/
private val WIRE_GYRO_LSB_PER_DEG_S = Gamepad.MOTION_GYRO_LSB_PER_DEG_S.toLong()
/** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */
private val ACCEL_NUMER = 2L * Gamepad.MOTION_ACCEL_LSB_PER_G
/** Bytes the layout below reads; the reports themselves are longer (41 / 37). */
private const val MIN_LEN = 35
/**
* What an unreadable pad gets: gyro straight through and accel on the nominal 8192
* LSB/g. Wrong by that unit's factory bias, and for gyro wrong by however far its
* scale sits from the wire's 20 — but a pad whose calibration cannot be read is far
* better off slightly mis-scaled than silent, so this never zeroes motion.
*/
val NOMINAL = MotionCal(
LongArray(3) { 1 },
LongArray(3) { 1 },
LongArray(3),
LongArray(3) { 2 * RAW_ACCEL_LSB_PER_G },
)
/**
* Parse a calibration feature report ([Model.calReportId]) — all little-endian i16:
* `[0]` report id, `[1..7)` gyro bias (pitch, yaw, roll), `[7..19)` gyro plus/minus
* INTERLEAVED (pitch+, pitch, yaw+, yaw, roll+, roll), `[19..23)` the two speed
* words, `[23..35)` accel plus/minus (x+, x, y+, y, z+, z).
*
* ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses
* before the three minuses and consumers switch layout on the transport — this path is
* USB-only by construction (see the file header), so do not "generalise" it.
*
* Falls back to [NOMINAL] for a failed read (null), a truncated or foreign reply, and
* per axis for a degenerate declaration — a clone or broken pad that declares zeroes
* would otherwise divide by zero (`hid-playstation` guards the same case, for the same
* reason).
*/
fun parse(blob: ByteArray?, reportId: Int): MotionCal {
if (blob == null || blob.size < MIN_LEN) return NOMINAL
if ((blob[0].toInt() and 0xFF) != reportId) return NOMINAL
val w = { o: Int ->
((blob[o + 1].toInt() shl 8) or (blob[o].toInt() and 0xFF)).toShort().toLong()
}
val speed2x = w(19) + w(21)
val gyroNumer = LongArray(3)
val gyroDenom = LongArray(3)
val accelBias = LongArray(3)
val accelRange = LongArray(3)
for (i in 0 until 3) {
val bias = w(1 + 2 * i)
val denom = abs(w(7 + 4 * i) - bias) + abs(w(9 + 4 * i) - bias)
if (speed2x > 0 && denom > 0) {
gyroNumer[i] = speed2x * WIRE_GYRO_LSB_PER_DEG_S
gyroDenom[i] = denom
} else {
gyroNumer[i] = 1 // passthrough, as before any calibration existed
gyroDenom[i] = 1
}
val plus = w(23 + 4 * i)
val range = plus - w(25 + 4 * i)
if (range > 0) {
accelBias[i] = plus - range / 2
accelRange[i] = range
} else {
accelBias[i] = 0 // nominal, as NOMINAL above
accelRange[i] = 2 * RAW_ACCEL_LSB_PER_G
}
}
return MotionCal(gyroNumer, gyroDenom, accelBias, accelRange)
}
}
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
@@ -50,8 +206,9 @@ object DsDevice {
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel arrive in WIRE units — the wire's
* `Motion` is a unit passthrough into the virtual pad's report, so the pad's raw counts are
* rescaled during the parse by the [MotionCal] handed to [parseState]. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
@@ -59,8 +216,8 @@ object DsDevice {
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val gyro = IntArray(3) // wire i16: 20 LSB per °/s (pitch/yaw/roll)
val accel = IntArray(3) // wire i16: 10000 LSB per g
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
@@ -108,15 +265,25 @@ object DsDevice {
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
*
* [cal] is this pad's own motion calibration, read once when the capture claims it; the
* default is the nominal fallback, which is all a caller without a live pad (the tests) can
* have.
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
fun parseState(
model: Model,
report: ByteArray,
len: Int,
out: State,
cal: MotionCal = MotionCal.NOMINAL,
): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
parseDs4(report, len, out, cal)
} else {
parseDs5(model, report, len, out)
parseDs5(model, report, len, out, cal)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
@@ -152,8 +319,8 @@ object DsDevice {
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 16 + 2 * i))
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 22 + 2 * i))
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
@@ -162,7 +329,7 @@ object DsDevice {
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
@@ -188,8 +355,8 @@ object DsDevice {
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 13 + 2 * i))
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 19 + 2 * i))
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import kotlin.math.roundToInt
/**
* Android gamepad capture punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host
@@ -54,6 +55,31 @@ object Gamepad {
const val AXIS_LT = 4
const val AXIS_RT = 5
// Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion
// sender on this client goes through the two converters below, so a scale that ever has to
// change changes in ONE place: the gyro program's first finding was a client sending 40× hot
// because a second copy of the number had drifted.
const val MOTION_GYRO_LSB_PER_DEG_S = 20
const val MOTION_ACCEL_LSB_PER_G = 10_000
/** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */
const val GRAVITY = 9.80665f
/** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */
const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat()
/** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */
fun motionGyroWire(radPerSec: Float): Int =
(radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767)
/**
* One acceleration component, Android's m/ the wire's signed-16 raw units. Android reports
* specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own
* convention no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly.
*/
fun motionAccelWire(mPerSecSq: Float): Int =
(mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767)
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
const val PREF_AUTO = 0
const val PREF_XBOX360 = 1
@@ -7,6 +7,7 @@ import android.os.Looper
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
/**
@@ -31,7 +32,8 @@ import java.util.concurrent.ConcurrentHashMap
*
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
* threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(
context: Context,
@@ -69,7 +71,18 @@ class GamepadRouter(
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
private class Slot(
val index: Int,
val mapper: Gamepad.AxisMapper,
/**
* Whether motion sent for this pad can reach the game at all, asked once at open off the
* kind it declared ([NativeBridge.nativePadMotionReaches]). False means the host built it a
* backend with no motion plane, so [deviceMotion] drops the sample here rather than paying
* to send one the host will decode and discard at a controller's full sensor rate, for
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
*/
val motionReaches: Boolean = true,
) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
@@ -85,12 +98,33 @@ class GamepadRouter(
private val slots = ConcurrentHashMap<Int, Slot>()
/**
* Invoked (main thread) with the deviceId whenever a slot closes hot-unplug or session teardown.
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
* lights bindings are released promptly instead of leaking until the feedback threads stop.
* deviceIds whose own gyro [PadSensors] is currently reading see [setDeviceHasSensorMotion].
* Written on the main thread, read from the phone-gyro thread, hence a concurrent set.
*/
private val sensorDevices: MutableSet<Int> =
Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())
/**
* Invoked (main thread) with the deviceId whenever a slot closes hot-unplug, a capture link's
* [releaseDevice] claim, or session teardown. `StreamScreen` wires this to
* `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are
* released promptly instead of leaking until the feedback threads stop, and to
* [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it.
*/
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
/**
* Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller the
* hot-plug callback or the first input from a pad the session started without. Not fired for
* [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from
* its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened].
*
* Slots opened in `init` (every controller already connected) predate any assignment here, so
* a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread
* inside one composition block, so nothing can slip between the sweep and the assignment.
*/
var onSlotOpened: ((deviceId: Int) -> Unit)? = null
/**
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] the caller
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
@@ -115,6 +149,17 @@ class GamepadRouter(
*/
var onMicChord: (() -> Unit)? = null
/**
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
* a session whose virtual pad has no motion plane its motion is not being sent, because every
* sample would be decoded and dropped host-side.
*
* It exists because the failure is otherwise completely silent: the gyro just does nothing, and
* from the couch that is indistinguishable from a broken sensor. The fix is the Controller type
* setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice.
*/
var onMotionUnreachable: (() -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null
@@ -324,14 +369,59 @@ class GamepadRouter(
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
/**
* Whether wire pad [pad] is held by a capture-link slot ([ExternalPad] USB DualSense /
* SC2), whose motion arrives from the pad's OWN IMU. The phone-gyro mirror stands down for
* those: two motion writers on one wire pad would fight. Synthetic ids are negative
* ([EXTERNAL_ID_BASE]); real [InputDevice] ids are positive. Read from the phone-gyro thread
* (the slot table is concurrent).
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU either a
* capture-link slot ([ExternalPad] USB DualSense / SC2; synthetic ids are negative
* ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro
* [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense /
* Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one
* wire pad would fight, and the pad's own IMU is the one attached to the player's hands.
* Read from the phone-gyro thread (both tables are concurrent).
*/
fun padHasOwnMotion(pad: Int): Boolean =
slots.any { (id, slot) -> slot.index == pad && id < 0 }
slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) }
/**
* Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation see
* [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the
* main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device
* rather than by pad index so a controller that changes wire index (a lower one freed up while
* it was captured) carries the fact with it.
*/
fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) {
if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId)
// This is the first moment we know a Bluetooth pad actually HAS a gyro — `openSlot` only
// knows what kind it declared. So it is the honest place to raise the notice when that
// gyro has nowhere to go, and the only one that cannot nag about a pad that never had one.
if (has && forwarding && slots[deviceId]?.motionReaches == false) {
onMotionUnreachable?.invoke()
}
}
/**
* One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its
* slot currently holds [ExternalPad.motion] for pads the input stack still owns. Silently
* drops when the slot is gone (unplugged, or claimed by a capture link between the sensor
* callback and here) rather than writing to an index that may already belong to someone else.
* Called from [PadSensors]' sensor thread.
*/
fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) {
val slot = slots[deviceId] ?: return
if (!forwarding) return
// The same gate the USB capture path takes: a backend with no motion plane decodes every
// sample and discards it, so sending is pure cost. Notified once per pad by
// [setDeviceHasSensorMotion], which is where we first know the controller HAS a gyro to
// lose — a pad without one must not produce a warning about motion.
if (!slot.motionReaches) return
NativeBridge.nativeSendPadMotion(
handle, slot.index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
/** Snapshot of the REAL controllers currently forwarded, as deviceIds the set [PadSensors]
* sweeps at start for the pads that were already connected when the session opened. */
fun forwardedDevices(): List<Int> = slots.keys.filter { it >= 0 }
/**
* A capture-link pad occupying a wire slot without an Android [InputDevice] the as-is Steam
@@ -339,7 +429,18 @@ class GamepadRouter(
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
* flush + Remove on [close], and full participation in the emergency exit chord.
*/
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
inner class ExternalPad internal constructor(
private val syntheticId: Int,
val index: Int,
/**
* Whether this pad's motion can reach the game at all, asked once at open (see
* [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend
* without a motion plane, so [motion] drops the sample here instead of paying to send one
* the host will decode and discard at a controller's full report rate, for the whole
* session.
*/
private val motionReaches: Boolean,
) {
// Live lookup instead of a captured reference: after [close] (or a router release) the
// slot is gone from the table and every entry point below degrades to a safe no-op.
private val slot get() = slots[syntheticId]
@@ -370,7 +471,7 @@ class GamepadRouter(
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null && forwarding) {
if (slot != null && forwarding && motionReaches) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -386,15 +487,26 @@ class GamepadRouter(
/**
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
* indices are taken. Main thread (like the hot-plug callbacks).
*
* [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion])
* true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2,
* whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries
* and which nothing here may second-guess. It gates only the notice: a pad that never sends
* motion must not produce a warning about motion.
*/
fun openExternal(pref: Int): ExternalPad? {
fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? {
val index = lowestFreeIndex() ?: return null
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
// Asked once, here, off the kind this pad just DECLARED — not off the session's resolved
// backend, which under Automatic answers for whichever pad happened to be active at dial
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
return ExternalPad(syntheticId, index, motionReaches)
}
/**
@@ -450,8 +562,18 @@ class GamepadRouter(
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
// which under Automatic answers for whichever pad happened to be active at dial time. Held
// for the slot's life; the sensor path reads it on every sample.
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index),
NativeBridge.nativePadMotionReaches(handle, pref),
)
slots[dev.id] = slot
// After the table holds the slot, so a listener that sends on this device the moment it is
// told ([PadSensors]) finds an index to send on rather than dropping its first samples.
onSlotOpened?.invoke(dev.id)
return slot
}
@@ -442,6 +442,42 @@ class HidUsbLink(
return n >= 0
}
/**
* Read one report back OUT of the device HID `GET_REPORT`, the EP0 mirror of [sendReport].
* [type] is [REPORT_TYPE_FEATURE] (or output), [id] the report number, [len] the report's full
* declared size INCLUDING its leading id byte, which a numbered report echoes back in byte 0
* (hidapi framing). Returns what arrived truncated if the device answered short or null
* when the device refuses the request or the link is down.
*
* **Once, at claim time; never per input report.** EP0 is independent of the interrupt
* endpoints (see [sendReport]), so this is safe alongside the reader thread but it BLOCKS the
* calling thread for up to [WRITE_TIMEOUT_MS], and a blocking control transfer in the report
* path would wreck capture latency. The one caller reads a Sony pad's fixed motion calibration
* when the capture engages ([DsCapture]).
*/
fun getReport(type: Int, id: Int, len: Int): ByteArray? {
if (len <= 0) return null
val conn = connection ?: return null
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return null
val buf = ByteArray(len)
val n = runCatching {
conn.controlTransfer(
0xA1, // device→host, class, interface
0x01, // GET_REPORT
(type shl 8) or id,
ifId,
buf,
buf.size,
WRITE_TIMEOUT_MS,
)
}.getOrDefault(-1)
return when {
n >= len -> buf
n > 0 -> buf.copyOf(n)
else -> null
}
}
/**
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
*
@@ -469,12 +505,13 @@ class HidUsbLink(
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
companion object {
private const val READ_TIMEOUT_MS = 100L
private const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
private const val ERROR_UNPLUG_MS = 2000L
private const val REPORT_TYPE_OUTPUT = 0x02
/** HID feature-report type — public for [getReport] callers ([writeRaw] takes a kind). */
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -0,0 +1,63 @@
package io.unom.punktfunk.kit
/**
* The hand-off of one claim's motion calibration, from the thread that reads it off the pad to the
* link thread that scales every input report with it.
*
* [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread
* it is a blocking EP0 control transfer and the claim runs on the UI's thread so the value lands
* a moment after the capture goes live. Reports in that gap are scaled by
* [DsDevice.MotionCal.NOMINAL] and forwarded like any other ([effective]): for about a millisecond
* the pad behaves exactly as it did before the calibration read existed acceleration a little
* short, gyro unscaled which nobody can feel, whereas a pad that ignores its buttons until an
* EP0 read comes back is very obvious.
*
* What the hand-off is actually for is the two things that gap must NOT do, neither of which a
* plain field gives:
*
* - **Fall back to the previous pad's numbers instead of the nominal ones.** Calibration is per
* unit, so the last controller's scale factors are simply wrong for this one more wrong, in
* general, than the nominal constants. [begin] forgets them, which is what makes the gap
* nominal rather than inherited.
* - **Let a read that outlived its claim publish.** An unplug, a [DsCapture.stop] and a fast
* re-claim can all land while a read is in flight; [publish] only accepts a value whose token is
* still the live claim's, so a straggler can never scale a pad it never read.
*
* Thread-safe: claimed and ended by the claiming thread, published by the reading thread, read by
* the link thread.
*/
internal class MotionCalHandoff {
/** Handed out by [begin] and burned by [end] — never reused, so a straggler can't match. */
private var token = 0
@Volatile private var cal: DsDevice.MotionCal? = null
/**
* The calibration to scale the next report with: the live claim's own, or the nominal fallback
* while its read is still in flight. Never null a report is always forwarded, never held
* back waiting for a control transfer.
*/
val effective: DsDevice.MotionCal get() = cal ?: DsDevice.MotionCal.NOMINAL
/** Open a claim: forget the previous pad's calibration, and take this claim's token. */
@Synchronized
fun begin(): Int {
cal = null
return ++token
}
/** End the live claim. Nothing read under an older token can land after this. */
@Synchronized
fun end() {
cal = null
token++
}
/** Publish [value] if [claim] is still the live claim; returns whether it landed. */
@Synchronized
fun publish(claim: Int, value: DsDevice.MotionCal): Boolean {
if (claim != token) return false
cal = value
return true
}
}
@@ -519,6 +519,23 @@ object NativeBridge {
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
/**
* Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed
* to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped
* by a host backend without a motion plane the X-Box classes have no gyro in their HID
* contract.
*
* Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than
* reconstructed here from the session's requested/resolved prefs. The rule is subtler than it
* looks (the host builds each pad from its OWN declaration and folds what it cannot build, so
* neither the declaration nor the session echo answers it alone) and every way of getting it
* wrong is silent, so it lives in one place with one set of tests.
*
* Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead "don't
* suppress" is the safe answer whenever we cannot tell.
*/
external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean
/**
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
@@ -0,0 +1,247 @@
package io.unom.punktfunk.kit
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.InputDevice
import java.util.concurrent.ConcurrentHashMap
/**
* Motion from a controller the Android input stack owns the Bluetooth pads.
*
* Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad
* claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a
* DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither it arrives as an
* ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a
* whole class of controller with no motion at all.
*
* Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a
* [android.hardware.SensorManager] scoped to that one controller, carrying the usual
* TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller
* that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index
* through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert
* those pads keep working, minus motion, exactly as they did.
*
* It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem:
* - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
* sensor batching would trade away the exact latency gyro aim exists to avoid, and the main
* thread is where Compose recomposition lives;
* - a feed torn down while its wire pad is still alive parks the rotation at zero first, because
* the host holds motion as STATE and re-emits it in every virtual-pad report: an angular
* velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent
* forever" finding).
*
* One writer per pad, three ways:
* 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb]
* calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires
* `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the
* controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to
* register but the explicit teardown is what makes the ordering deterministic instead of a
* race against the platform's own removal callback.
* 2. The phone-gyro mirror stands down: registering flips
* [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and
* [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge).
* 3. Exactly one feed exists per deviceId [onSlotOpened] is idempotent, and it is the only
* thing that ever constructs one.
*
* Frame: see [gyroToWire] the mapping is straight through, and NOT yet verified on hardware.
*/
class PadSensors(private val router: GamepadRouter) {
/** One controller's live sensor feed: its listener state and the accel it pairs with each
* rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */
private inner class Feed(private val deviceId: Int) : SensorEventListener {
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample).
* Starts at the host's neutral 1 g on the up axis, NOT [0,0,0], which is free fall. */
private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
private val gyro = IntArray(3)
/** Whether any rotation has gone out on this pad gates the park on teardown, so a pad
* that never sent motion is not handed a sample it did not earn. */
@Volatile
var wroteMotion = false
private set
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel)
Sensor.TYPE_GYROSCOPE -> {
gyroToWire(event.values, gyro)
// One line per controller per session, on the first sample that carries both
// planes: it is the cheapest possible version of the frame measurement
// [gyroToWire] asks for. Hold the pad flat and still while a stream starts and
// the accel triple says which slot gravity lands on — the one thing that
// settles whether the straight-through mapping is right.
if (!wroteMotion) {
Log.i(
TAG,
"controller $deviceId first motion sample: " +
"gyro ${gyro.joinToString()} accel ${accel.joinToString()}",
)
}
wroteMotion = true
router.deviceMotion(deviceId, gyro, accel)
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
/** Zero rotation, last-known accel — "at rest", not free fall. */
fun park() {
gyro.fill(0)
router.deviceMotion(deviceId, gyro, accel)
}
}
/** deviceId live feed. Concurrent: the main thread mutates it while the sensor thread is
* running (hot-plug, a capture link's claim). */
private val feeds = ConcurrentHashMap<Int, Feed>()
private val thread = HandlerThread("pf-pad-sensors")
private var handler: Handler? = null
/**
* Start the sensor thread and attach to every controller the router already forwards the
* pads connected before the session opened, which will never fire a hot-plug callback.
* Everything after that arrives through [onSlotOpened]. Main thread.
*/
fun start() {
if (!supported()) return
thread.start()
handler = Handler(thread.looper)
for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId)
}
/**
* A slot opened for real controller [deviceId] attach if it has a gyroscope of its own.
* Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from
* [GamepadRouter.onSlotOpened].
*/
fun onSlotOpened(deviceId: Int) {
val h = handler ?: return
if (feeds.containsKey(deviceId)) return
// API 31+ only — getSensorManager does not exist below it. Re-checked here rather than
// relying on start()'s gate, so the entry point is safe on its own terms.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val dev = InputDevice.getDevice(deviceId) ?: return
// Declared non-null: a controller with no sensors gets an empty manager, not a null one.
val sm = dev.sensorManager
// A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation
// is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation
// at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the
// two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays
// on the mirror's terms instead, where at least the accel agrees with the gyro beside it.
// Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its
// own evdev node, and whether InputReader merges that node onto the gamepad InputDevice
// (shared descriptor) or leaves it standing alone is the platform's business, not ours —
// and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this
// would never see it. Android 12's own controller-sensor documentation cites the DualShock
// 4 and DualSense, which says the merge happens; it is not something this code can assert.
// If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check.
val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
val feed = Feed(deviceId)
feeds[deviceId] = feed
// ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is
// what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway.
sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
}
// The pad sources its own rotation from here on → the phone-gyro mirror stands down for it.
router.setDeviceHasSensorMotion(deviceId, true)
Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion")
}
/**
* The slot for [deviceId] closed unplug, session teardown, or a capture link claiming the
* device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from
* [GamepadRouter.onSlotClosed].
*
* No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback
* and has already sent that pad's Remove, so the host tore the virtual pad down and there is no
* latched rotation left to clear while writing to a wire index that is free again would be
* addressing whoever claims it next. [stop] is the case where the pad outlives the feed.
*/
fun onSlotClosed(deviceId: Int) {
unregister(deviceId)
router.setDeviceHasSensorMotion(deviceId, false)
}
/**
* Unregister every listener, join the sensor thread, then park at zero each pad that was
* rotating. Call BEFORE the router is released and the session handle freed the same
* teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come
* AFTER the join for two reasons: a sample still in flight would re-latch the rotation just
* cleared, and the join is what publishes the sensor thread's writes to this one.
*/
fun stop() {
val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } }
for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false)
thread.quitSafely()
runCatching { thread.join() }
handler = null
for ((_, feed) in parked) if (feed.wroteMotion) feed.park()
}
/**
* Drop [deviceId]'s listeners, returning the feed that held them (null if there was none).
* Safe for a controller that is already gone: the sensor manager is reached through the
* [InputDevice], and a vanished device simply leaves nothing to unregister the platform has
* stopped calling the listener either way.
*/
private fun unregister(deviceId: Int): Feed? {
val feed = feeds.remove(deviceId) ?: return null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed)
}
return feed
}
companion object {
private const val TAG = "PadSensors"
/** Whether this platform can read a controller's own sensors at all (API 31+). */
fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* One gyroscope sample (Android: rad/s) the wire's three signed-16 components, in place.
*
* The axis frame is straight through, and that is now MEASURED rather than assumed.
*
* The wire is a unit passthrough into a virtual DualSense report, whose frame was measured
* over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward
* toward the player (roll), right-handed. Android hands a controller's own sensors over in
* that same frame which was the documented expectation, but the numbers pass through a
* HID driver and InputFlinger's sensor mapper, either of which could have permuted or
* negated without saying so.
*
* Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming
* to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` exactly 1 g
* on slot 1 and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y
* with every rotation driving its correctly-named axis (yawRY, pitchRX, rollRZ) and the
* signs agreeing with gravity's independent witness on 95 of 100 rotating samples.
*
* So: no remap. If a future device disagrees, the remap belongs HERE with its own
* expectations in `PadSensorsTest` not spread across callers.
*/
fun gyroToWire(values: FloatArray, out: IntArray) {
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
}
/**
* One accelerometer sample (Android: m/, specific force) the wire's three signed-16
* components, in place. Same measured frame as [gyroToWire] and the same straight-through
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) which is precisely
* what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat.
*/
fun accelToWire(values: FloatArray, out: IntArray) {
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
}
}
}
@@ -42,12 +42,13 @@ class DeviceGyroTest {
}
}
/** The wire contract, shared with pf-client-core / the Swift client: 20 LSB/°·s means
* 1 rad/s ~1145.9 raw; 1 g 10000 raw. */
/** The wire contract, shared with pf-client-core / the Swift client and now with every other
* Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ~1145.9 raw;
* 1 g 10000 raw. */
@Test
fun wireUnitConstants() {
assertEquals(20f * 180f / Math.PI.toFloat(), DeviceGyro.GYRO_LSB_PER_RAD_S, 0f)
assertEquals(1145.9156f, DeviceGyro.GYRO_LSB_PER_RAD_S, 0.001f)
assertEquals(10_000, DeviceGyro.ACCEL_LSB_PER_G)
assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f)
assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f)
assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G)
}
}
@@ -151,6 +151,185 @@ class DsDeviceTest {
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
}
// ---- IMU calibration (the pad's own scale factors) ----
/**
* A calibration feature report in the pads' USB layout: report id, three gyro bias words, six
* INTERLEAVED gyro plus/minus words, the two speed words, six accel plus/minus words all
* little-endian i16, exactly what [DsDevice.MotionCal.parse] reads and what
* `crates/pf-inject/tests/motion_contract.rs` writes from the other end.
*/
private fun calBlob(
id: Int,
gyroBias: IntArray,
gyroPlus: IntArray,
gyroMinus: IntArray,
speed: Int,
accelPlus: IntArray,
accelMinus: IntArray,
len: Int = 41,
): ByteArray = ByteArray(len).also { b ->
fun put(o: Int, v: Int) {
b[o] = (v and 0xFF).toByte()
b[o + 1] = ((v shr 8) and 0xFF).toByte()
}
b[0] = id.toByte()
for (i in 0 until 3) {
put(1 + 2 * i, gyroBias[i])
put(7 + 4 * i, gyroPlus[i])
put(9 + 4 * i, gyroMinus[i])
put(23 + 4 * i, accelPlus[i])
put(25 + 4 * i, accelMinus[i])
}
put(19, speed)
put(21, speed)
}
/**
* A realistic DualSense blob: gyro measured at 512 °/s each way over ±8192 counts about a
* small factory bias 16384/1024 = 16 raw LSB per °/s, the ±2000 °/s full scale a real pad
* has and accel spanning about ±8192 counts (`DS_ACC_RES_PER_G`) about a per-axis zero point
* that is NOT zero. Both are the shape a nominal constant cannot express.
*/
private fun realisticCal(): DsDevice.MotionCal = DsDevice.MotionCal.parse(
calBlob(
id = 0x05,
gyroBias = intArrayOf(10, -6, 3),
gyroPlus = intArrayOf(10 + 8192, -6 + 8192, 3 + 8192),
gyroMinus = intArrayOf(10 - 8192, -6 - 8192, 3 - 8192),
speed = 512, // speed_plus + speed_minus = 1024
accelPlus = intArrayOf(8300, 8200, 8000),
accelMinus = intArrayOf(-8100, -8192, -8384),
),
0x05,
)
@Test
fun calibrationRescalesRawCountsOntoTheWireUnits() {
val cal = realisticCal()
// 100 °/s at this pad's 16 LSB per °/s = 1600 raw → the wire's 20 LSB per °/s = 2000.
for (axis in 0 until 3) {
assertEquals(2000, cal.gyroToWire(axis, 1600))
assertEquals(-2000, cal.gyroToWire(axis, -1600))
assertEquals(0, cal.gyroToWire(axis, 0))
}
// 1 g = the axis's zero point plus half its declared 2 g range → 10000 wire units.
val zero = intArrayOf(100, 4, -192) // plus range/2, per axis
val oneG = intArrayOf(8300, 8200, 8000) // = accelPlus
for (axis in 0 until 3) {
assertEquals(10000, cal.accelToWire(axis, oneG[axis]))
assertEquals(0, cal.accelToWire(axis, zero[axis]))
assertEquals(-10000, cal.accelToWire(axis, zero[axis] - (oneG[axis] - zero[axis])))
}
// Both rescales are >1 here, so full-scale raw must clamp rather than wrap the i16.
assertEquals(32767, cal.gyroToWire(0, 30000))
assertEquals(-32768, cal.gyroToWire(0, -30000))
assertEquals(32767, cal.accelToWire(0, 30000))
// The capture logs this, and it is the discriminator the owed on-glass check reads: a pad
// whose blob was read declares its own resolution, the fallback declares the wire's.
assertTrue(cal.toString().startsWith("gyro 16/16/16 LSB/°·s"))
assertTrue(DsDevice.MotionCal.NOMINAL.toString().startsWith("gyro 20/20/20 LSB/°·s"))
}
/**
* The host's own virtual pads declare `DS_FEATURE_CALIBRATION` (`dualsense_proto.rs`) a blob
* that states the wire's units exactly. Reading it back must therefore be a passthrough: if
* this ever stops holding, the client and the host disagree about what a motion sample means.
*/
@Test
fun theHostsOwnBlobIsAPassthrough() {
val cal = DsDevice.MotionCal.parse(
calBlob(
id = 0x05,
gyroBias = intArrayOf(0, 0, 0),
gyroPlus = intArrayOf(10000, 10000, 10000),
gyroMinus = intArrayOf(-10000, -10000, -10000),
speed = 500,
accelPlus = intArrayOf(10000, 10000, 10000),
accelMinus = intArrayOf(-10000, -10000, -10000),
),
0x05,
)
for (axis in 0 until 3) {
assertEquals(2000, cal.gyroToWire(axis, 2000)) // 100 °/s
assertEquals(10000, cal.accelToWire(axis, 10000)) // 1 g
assertEquals(-1234, cal.gyroToWire(axis, -1234))
}
}
/**
* Anything unusable keeps the pre-calibration behaviour accel on the nominal 8192 LSB/g,
* gyro straight through. A pad with no readable calibration is better off slightly mis-scaled
* than silent, so nothing here may zero motion.
*/
@Test
fun unusableCalibrationFallsBackInsteadOfZeroing() {
val degenerate = calBlob(
id = 0x02,
gyroBias = intArrayOf(0, 0, 0),
gyroPlus = intArrayOf(0, 0, 0),
gyroMinus = intArrayOf(0, 0, 0),
speed = 0,
accelPlus = intArrayOf(0, 0, 0),
accelMinus = intArrayOf(0, 0, 0),
len = 37,
)
val cals = listOf(
DsDevice.MotionCal.NOMINAL,
DsDevice.MotionCal.parse(null, 0x05), // the GET_REPORT failed
DsDevice.MotionCal.parse(ByteArray(8) { if (it == 0) 0x05 else 0 }, 0x05), // short reply
DsDevice.MotionCal.parse(degenerate, 0x02), // a clone pad's zeroes
DsDevice.MotionCal.parse(degenerate, 0x05), // someone else's report id
)
for (cal in cals) {
for (axis in 0 until 3) {
assertEquals(1234, cal.gyroToWire(axis, 1234)) // passthrough
assertEquals(10000, cal.accelToWire(axis, 8192)) // 8192 raw LSB = 1 g
assertEquals(-10000, cal.accelToWire(axis, -8192))
}
}
}
/** The parse applies the calibration at the motion offsets, per model, and defaults to nominal. */
@Test
fun parseStateAppliesTheCalibration() {
val cal = realisticCal()
// DS5: gyro at [16..22), accel at [22..28). Pitch = 1600 raw (100 °/s), accel z = 8000 (1 g).
val ds5 = ds5Report {
it[16] = 0x40; it[17] = 0x06 // 1600
it[26] = 0x40; it[27] = 0x1F // 8000
}
val five = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, five, cal))
assertEquals(2000, five.gyro[0])
assertEquals(10000, five.accel[2])
// DS4: gyro at [13..19), accel at [19..25). Same numbers, same answers.
val ds4 = ds4Report {
it[13] = 0x40; it[14] = 0x06
it[23] = 0x40; it[24] = 0x1F
}
val four = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4, 64, four, cal))
assertEquals(2000, four.gyro[0])
assertEquals(10000, four.accel[2])
// No calibration argument = the nominal fallback: gyro through, accel ×10000/8192.
val nominal = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, nominal))
assertEquals(1600, nominal.gyro[0])
assertEquals(8000L * 10000 / 8192, nominal.accel[2].toLong())
}
/** Each model asks for the feature report its firmware actually serves over USB. */
@Test
fun calibrationReportIdentityPerModel() {
assertEquals(0x05, DsDevice.Model.DUALSENSE.calReportId)
assertEquals(41, DsDevice.Model.DUALSENSE.calReportLen)
assertEquals(0x05, DsDevice.Model.DUALSENSE_EDGE.calReportId)
assertEquals(41, DsDevice.Model.DUALSENSE_EDGE.calReportLen)
assertEquals(0x02, DsDevice.Model.DUALSHOCK4.calReportId)
assertEquals(37, DsDevice.Model.DUALSHOCK4.calReportLen)
}
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
@Test
@@ -0,0 +1,152 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming
* thread. Two things are pinned here, and both are about the gap before the read comes back.
*
* What the gap DOES: the pad streams, scaled by the nominal calibration the behaviour that
* shipped before the read existed. What it must NOT do: inherit the previous pad's factory numbers
* (calibration is per unit), or accept a read that outlived its claim, which an unplug, a stop, or
* a re-claim can all cause.
*/
class MotionCalHandoffTest {
/**
* A calibration whose gyro reads [rawLsbPerDegS] raw LSB per °/s and whose accel sits at
* [accelZero] raw counts at 0 g, so two of them are told apart by what they DO identity
* alone would let a regression that returns the wrong instance still look right.
*/
private fun cal(rawLsbPerDegS: Int, accelZero: Int = 0): DsDevice.MotionCal {
val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000
val span = rawLsbPerDegS * 1000 // |plus bias| + |minus bias| = span
val blob = ByteArray(41)
fun put(o: Int, v: Int) {
blob[o] = (v and 0xFF).toByte()
blob[o + 1] = ((v shr 8) and 0xFF).toByte()
}
blob[0] = 0x05
for (i in 0 until 3) {
put(7 + 4 * i, span / 2) // gyro plus
put(9 + 4 * i, -span / 2) // gyro minus
put(23 + 4 * i, accelZero + 8192) // accel plus / minus: 8192 raw LSB per g
put(25 + 4 * i, accelZero - 8192)
}
put(19, speed)
put(21, speed)
return DsDevice.MotionCal.parse(blob, 0x05)
}
/** One DS5 input report: cross held, sticks centred, gyro pitch 1600 raw, accel z 8000 raw. */
private fun report(): ByteArray = ByteArray(64).also {
it[0] = 0x01
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[8] = (0x08 or 0x20).toByte() // hat neutral | cross
it[16] = 0x40; it[17] = 0x06 // gyro pitch = 1600
it[26] = 0x40; it[27] = 0x1F // accel z = 8000
it[33] = 0x80.toByte(); it[37] = 0x80.toByte() // no touch contacts
}
@Test
fun `a claim scales nominally until its read lands`() {
val h = MotionCalHandoff()
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
val claim = h.begin()
assertSame("the read is in flight — scale nominally, do not wait", DsDevice.MotionCal.NOMINAL, h.effective)
val read = cal(16)
assertTrue(h.publish(claim, read))
assertSame(read, h.effective)
}
/**
* The whole point of scaling nominally instead of holding reports back: a pad answers its
* buttons from the first report, and only its motion changes when the calibration arrives.
*/
@Test
fun `a report in the gap is forwarded, nominally scaled, and rescales once the read lands`() {
val h = MotionCalHandoff()
val claim = h.begin()
val r = report()
val gap = DsDevice.State()
assertTrue(
"a report must still be parsed while the read is in flight",
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, gap, h.effective),
)
assertEquals("buttons reach the wire immediately", Gamepad.BTN_A, gap.buttons)
assertEquals("and so do sticks", 128, gap.lsX)
assertEquals("nominal gyro is the raw count", 1600, gap.gyro[0])
assertEquals("nominal accel is ×10000/8192", 8000L * 10000 / 8192, gap.accel[2].toLong())
assertTrue(h.publish(claim, cal(16, accelZero = 100)))
val live = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, live, h.effective))
assertEquals("buttons do not depend on the calibration", gap.buttons, live.buttons)
assertEquals("1600 raw at 16 LSB/°·s = 100 °/s = 2000 wire", 2000, live.gyro[0])
assertNotEquals("the same raw report must convert differently now", gap.gyro[0], live.gyro[0])
assertNotEquals(gap.accel[2], live.accel[2])
}
@Test
fun `a read that outlived its claim publishes nothing`() {
val h = MotionCalHandoff()
val claim = h.begin()
h.end() // unplug, or DsCapture.stop, while the read was in flight
assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16)))
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
}
@Test
fun `a new claim scales nominally rather than inheriting the previous pad's calibration`() {
val h = MotionCalHandoff()
val first = h.begin()
val hot = cal(4, accelZero = 400) // a pad reading 4 raw LSB per °/s, well off nominal
assertTrue(h.publish(first, hot))
assertSame(hot, h.effective)
// Re-claimed without an end() in between — the pad was swapped while a read was in flight.
val second = h.begin()
assertNotEquals(first, second)
assertSame(
"the next pad starts on the nominal scaling, NOT the last pad's factory numbers",
DsDevice.MotionCal.NOMINAL,
h.effective,
)
assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot))
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
// And that fallback is a real difference, not two names for the same numbers: the inherited
// calibration would have turned this pad's motion into something else entirely.
val r = report()
val nominal = DsDevice.State()
val inherited = DsDevice.State()
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, nominal, h.effective)
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, inherited, hot)
assertNotEquals(inherited.gyro[0], nominal.gyro[0])
assertNotEquals(inherited.accel[2], nominal.accel[2])
val slow = cal(32)
assertTrue(h.publish(second, slow))
assertSame(slow, h.effective)
}
@Test
fun `ending a claim twice still refuses every outstanding token`() {
val h = MotionCalHandoff()
val claim = h.begin()
h.end() // DsCapture.stop
h.end() // …and the unplug that followed it
assertFalse(h.publish(claim, cal(16)))
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
val next = h.begin()
assertNotEquals(claim, next)
val read = cal(16)
assertTrue(h.publish(next, read))
assertSame(read, h.effective)
}
}
@@ -0,0 +1,92 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
* JVM the two `*ToWire` functions take plain float arrays and touch no Android class.
*
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
* not drift. The axis mapping is straight through and NOT yet verified against hardware see
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
* to make a future remap a deliberate, visible edit rather than a quiet one.
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class PadSensorsTest {
private fun gyro(x: Float, y: Float, z: Float) =
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
private fun accel(x: Float, y: Float, z: Float) =
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
@Test
fun gyroScaleFromRadiansPerSecond() {
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
assertEquals(0, gyro(0f, 0f, 0f)[0])
}
/** 10000 LSB/g from Android's m/: standard gravity is exactly 1 g. Android reports specific
* force, so a pad at rest reads +1 g on the axis pointing up no sign flip anywhere. */
@Test
fun accelScaleFromMetresPerSecondSquared() {
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
assertEquals(0, accel(0f, 0f, 0f)[1])
}
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
* DualSense 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
* not the [0,0,0] that means free fall. */
@Test
fun restingPadIsTheHostNeutral() {
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
}
/**
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
* planes, with no permutation and no negation. UNVERIFIED against hardware if a Bluetooth
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
*/
@Test
fun straightThroughFrame() {
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
assertArrayEquals(
intArrayOf(10_000, 20_000, -30_000),
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
)
}
/** Both planes clamp to signed 16 bits rather than wrapping a flick past 1638 °/s or a knock
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
@Test
fun clampsToSigned16() {
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
}
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
* is a gyro that drifts. */
@Test
fun roundsToNearestNotTowardZero() {
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
}
/** A sensor that hands back fewer than three components (or none the framework reuses one
* array across types) contributes zero rather than throwing on the sensor thread. */
@Test
fun shortSampleIsZeroFilled() {
val out = IntArray(3) { 7 }
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
assertArrayEquals(intArrayOf(3600, 0, 0), out)
}
}
@@ -361,6 +361,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
);
}
/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that
/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can
/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane.
///
/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one
/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests.
/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting
/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing
/// into a host that drops every one.
///
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
/// the `Auto` rule inside the predicate itself.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
_env: JNIEnv,
_this: JObject,
handle: jlong,
declared_pref: jint,
) -> jboolean {
if handle == 0 {
return 1;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
// values read behind `&self`.
let h = unsafe { &*(handle as *const SessionHandle) };
let declared =
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
u8::from(punktfunk_core::config::pad_motion_reaches(
declared,
h.client.requested_gamepad,
h.client.resolved_gamepad,
))
}
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
@@ -780,6 +780,15 @@ struct ContentView: View {
// other in the seconds where they overlap.
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
// A forwarded pad has a gyro this session's virtual controller cannot
// carry. Shown briefly at every stats tier and with the overlay off: the
// failure is otherwise completely silent the gyro just does nothing
// and the fix is a setting, so the hint has to name it. Every platform,
// including tvOS, where a DualSense is an ordinary way to play.
if captureEnabled, model.motionUnreachableKind != nil {
MotionUnreachableBadge()
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#if !os(tvOS)
// Shown for as long as the mic is muted, at every stats tier and with the
// overlay off see MicMutedBadge. tvOS has no microphone to mute.
@@ -164,6 +164,20 @@ final class SessionModel: ObservableObject {
/// background's privacy mute never clears the user's choice. Local and instant: it gates
/// capture on this device, nothing is sent to the host.
@Published private(set) var micMuted = false
/// The kind a controller declared when it turned out this session cannot carry its motion
/// set once per such pad, cleared after `motionHintSeconds`. Nil the rest of the time.
///
/// It exists because the failure is otherwise entirely silent: the gyro simply does nothing,
/// with no way for the player to tell a dead sensor from a session that resolved a backend
/// without a motion plane. The fix is a settings change, so the hint has to name it.
@Published private(set) var motionUnreachableKind: PunktfunkConnection.GamepadType?
/// Drops `motionUnreachableKind` again held so a second pad's hint replaces the first
/// cleanly, and so ending the session cancels a pending clear rather than letting it fire
/// into a torn-down model.
private var motionHintTimer: Task<Void, Never>?
/// How long the motion hint stays up the start-of-stream shortcut banner's 6 s, since the
/// two share the bottom-centre stack and a player reads them the same way.
private static let motionHintSeconds: UInt64 = 6
/// Resize overlay (design/midstream-resolution-resize.md client resize UX): true from the
/// instant a Match-window resize starts steering toward a new size until a frame at that size
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
@@ -535,6 +549,21 @@ final class SessionModel: ObservableObject {
applyMicMute()
}
/// A forwarded controller has a gyro this session cannot carry (see
/// `GamepadCapture.onMotionUnreachable`). Show it briefly, then let it go.
///
/// Last pad wins, and its timer restarts: two such pads are the same one fact to a player, and
/// a second hint appearing under a still-visible first would only read as a stutter.
private func noteMotionUnreachable(_ kind: PunktfunkConnection.GamepadType) {
motionUnreachableKind = kind
motionHintTimer?.cancel()
motionHintTimer = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
guard !Task.isCancelled else { return }
self?.motionUnreachableKind = nil
}
}
/// Push the EFFECTIVE mute the user's choice OR the background keep-alive's privacy mute
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
/// changed, the other still holds, so returning from the background can't un-mute a user who
@@ -584,6 +613,11 @@ final class SessionModel: ObservableObject {
// The mic mute is per-session and never persisted: the next stream starts live (if the
// mic is enabled), rather than silently carrying a mute nobody remembers making.
micMuted = false
// Cancel before clearing: a pending clear firing into a torn-down session would be
// harmless but pointless, and leaving the hint set would carry it into the next stream.
motionHintTimer?.cancel()
motionHintTimer = nil
motionUnreachableKind = nil
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the
@@ -741,6 +775,9 @@ final class SessionModel: ObservableObject {
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
// A pad with a gyro that this session cannot carry say so once, briefly, and name the
// setting that fixes it. Already main-actor (GamepadCapture fires it there).
capture.onMotionUnreachable = { [weak self] kind in self?.noteMotionUnreachable(kind) }
capture.start()
gamepadCapture = capture
let feedback = GamepadFeedback(connection: conn, manager: .shared)
@@ -305,6 +305,39 @@ struct StreamHUDView: View {
}
}
/// "This pad's gyro can't reach the game" shown briefly when a forwarded controller with motion
/// meets a session whose virtual controller has no motion plane (an X-Box class pad has no gyro in
/// its HID contract, so every sample would be decoded and dropped).
///
/// Not a control, unlike `MicMutedBadge`: the fix is the Controller type setting, which is not
/// reachable mid-stream on every platform, and changing it applies from the next session anyway.
/// So this states the fact and names the setting, in the HUD's glass language, and gets out of the
/// way the alternative is what shipped before, which was a gyro that silently did nothing with
/// no way to tell that from a broken sensor.
///
/// Every platform: a DualSense on an Apple TV is an ordinary way to play, and it is exactly the
/// pad this can happen to.
struct MotionUnreachableBadge: View {
var body: some View {
HStack(spacing: 7) {
Image(systemName: "gyroscope")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.yellow)
Text("Motion won't reach this session — set Controller type to DualSense")
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityElement(children: .combine)
.accessibilityLabel(
"This controller's motion will not reach the game. "
+ "Set Controller type to DualSense to enable it.")
}
}
#if !os(tvOS)
/// The muted-microphone badge the mute STATE, as opposed to the buttons that flip it. It rides
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
@@ -311,6 +311,51 @@ public final class PunktfunkConnection {
default: return nil
}
}
/// Whether this backend has a motion plane at all whether a `sendMotion` sample to a
/// host running it can reach the game, or is decoded and dropped. Mirrors the host's
/// `GamepadPref::has_motion`; the X-Box classes have no gyro in their HID contract.
///
/// This answers for ONE backend. To ask it of a particular pad, go through
/// `PunktfunkConnection.motionReaches(declared:)` `resolvedGamepad` is not that pad's
/// answer, because the host builds each virtual device from the pad's own
/// `gamepadArrival` and falls back to the session default only for a pad that never
/// declared one.
///
/// `.auto` answers `true` on purpose: it means "unknown" an older host that omitted the
/// echo, which may well have resolved a DualSense. Suppressing on unknown would silently
/// break a working gyro, which is the worse of the two failures.
public var hasMotion: Bool {
switch self {
case .auto: return true // unknown; assume it can, see above
case .xbox360, .xboxOne: return false
case .dualSense, .dualShock4, .dualSenseEdge, .switchPro,
.steamController, .steamDeck, .steamController2:
return true
}
}
/// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad
/// announced in its `gamepadArrival`, `asked` is the session default the handshake carried,
/// and `resolved` is the host's echo. Mirrors punktfunk-core's `pad_motion_reaches`, which
/// carries the full argument; in short:
///
/// - the host builds each virtual device from that pad's declaration, so the echo is simply
/// not this pad's answer when the two differ (under "Automatic" the handshake carries the
/// ACTIVE pad's kind, so a couch with an X-Box pad and a DualSense echoes X-Box 360 while
/// the host builds the DualSense a working motion plane);
/// - the host FOLDS what it cannot build a Switch Pro on Windows, a UHID backend on a
/// host whose `/dev/uhid` is unusable and nothing here can predict that;
/// - but the echo IS one observed sample of that fold, for the kind we asked about, so it
/// is authoritative for a pad that declared exactly that.
///
/// Static and pure so it can be tested without a live session; the connection's
/// `motionReaches(declared:)` is the call site that fills in the other two.
public static func motionReaches(
declared: GamepadType, asked: GamepadType, resolved: GamepadType
) -> Bool {
declared == asked ? resolved.hasMotion : declared.hasMotion
}
}
/// The virtual gamepad backend the host actually resolved (the Welcome's echo of the
@@ -318,6 +363,18 @@ public final class PunktfunkConnection {
/// DualSense feedback.
public private(set) var resolvedGamepad: GamepadType = .auto
/// The session default this connection's handshake ASKED for, kept beside the host's answer
/// above. The pair is what makes the echo usable per pad see `motionReaches(declared:)`.
public private(set) var requestedGamepad: GamepadType = .auto
/// Whether motion sent for ONE pad can reach the game, given the kind that pad DECLARED in its
/// `gamepadArrival` (`GamepadManager.declaredKind(for:)`) this session's two halves of
/// `GamepadType.motionReaches(declared:asked:resolved:)`, which carries the reasoning.
public func motionReaches(declared: GamepadType) -> Bool {
GamepadType.motionReaches(
declared: declared, asked: requestedGamepad, resolved: resolvedGamepad)
}
/// The compositor the host actually resolved for this session's virtual output (the
/// Welcome's echo of the requested `compositor`, with `.auto` resolved to a concrete
/// backend). `.auto` = an older host that didn't say. Clients use it to decide
@@ -572,6 +629,9 @@ public final class PunktfunkConnection {
var gp: UInt32 = 0
_ = punktfunk_connection_gamepad(handle, &gp)
resolvedGamepad = GamepadType(rawValue: gp) ?? .auto
// What we asked for, straight off the parameter the echo above only speaks for a pad
// that declared this same kind (see `motionReaches(declared:)`).
requestedGamepad = gamepad
var comp: UInt32 = 0
_ = punktfunk_connection_compositor(handle, &comp)
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
@@ -174,11 +174,31 @@ public final class DeviceGyro {
state.lock.unlock()
let rot = r.apply(
x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z))
// Same total-acceleration convention as GamepadCapture.forwardMotion.
// Total acceleration, NEGATED the same convention as GamepadCapture.forwardMotion, which
// this file's header promises to track. Apple reports the gravity VECTOR (pointing down);
// an accelerometer measures proper acceleration (pointing up at rest), and the wire carries
// the latter. Without the minus a still phone told the host it was accelerating downward at
// 1 g.
let acc = r.apply(
x: Float(m.gravity.x + m.userAcceleration.x),
y: Float(m.gravity.y + m.userAcceleration.y),
z: Float(m.gravity.z + m.userAcceleration.z))
x: -Float(m.gravity.x + m.userAcceleration.x),
y: -Float(m.gravity.y + m.userAcceleration.y),
z: -Float(m.gravity.z + m.userAcceleration.z))
// NO frame conversion here, and that is not an oversight `GamepadCapture.forwardMotion`
// applies `GamepadWire.appleMotionToWire` and this deliberately does not.
//
// The trap is that two different frames are both called "the controller frame". GCMotion
// reports a CONTROLLER in (Right, Forward, Up) measured on a real DualSense which is
// not the wire's frame, hence the conversion over there. `r` above resolves THIS DEVICE
// into the frame the header describes: x right, y up, z out of the screen. For the pose
// this mirror exists to serve a phone clipped upright, screen facing the player "out of
// the screen" points AT the player, so that frame is (Right, Up, Backward), which IS the
// wire's frame. Straight through is already correct.
//
// Applying the controller path's conversion here was tried and was WRONG: a phone at rest
// would have reported gravity as 1 g on the roll axis instead of +1 g up, i.e. lying on
// its edge. Caught by measuring the Android twin, which does the same thing straight
// through and reads +1 g on the up axis end to end. If a future capture path needs a
// conversion, decide it from that source's OWN measured frame rather than by analogy.
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
let gyro = (
@@ -66,7 +66,6 @@ public final class GamepadCapture {
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
/// A motion sample went out on this pad `flush` then owes the wire a zero-gyro
/// sample: the host holds motion as STATE and re-emits it, so a nonzero angular
/// velocity left behind reads as endless rotation (the gyro-sweep latch).
@@ -96,9 +95,6 @@ public final class GamepadCapture {
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
private var slots: [Slot] = []
/// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate).
private static let motionIntervalNs: UInt64 = 4_000_000
/// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`):
/// L1+R1+Start+Select held together four simultaneous buttons no game uses, so normal
/// play can't trip it. Held for `disconnectHold` it ends the session via
@@ -135,6 +131,15 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
/// Fired ON MAIN, once per slot at open, when a controller that HAS a gyro was given a host
/// backend without a motion plane its motion is not being sent, because every sample would
/// be decoded and dropped. The argument is the kind this pad declared, so the UI can name it.
///
/// It fires at open rather than on the first sample precisely because nothing is sampled: the
/// IMU is never powered in this case (see `openSlot`), which is also what stops the pad
/// burning battery streaming gyro nobody reads.
public var onMotionUnreachable: ((PunktfunkConnection.GamepadType) -> Void)?
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
@@ -335,10 +340,43 @@ public final class GamepadCapture {
// 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) } }
//
// A host that built this pad a backend WITHOUT a motion plane is the same situation: every
// sample would be decoded and dropped, so there is equally nothing to forward. Asked per
// pad off what this slot declared, not off the session echo under "Automatic" a couch
// with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1
// a DualSense whose gyro works.
//
// Gated on `hasRotationRate`, not on `motion != nil`. An X-Box controller exposes a
// `GCMotion` that reports gravity and NOTHING else attaching to it streamed a
// permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration
// that says this pad has one. A game reading it sees a controller being held perfectly
// still forever, which is worse than seeing no motion plane at all: there is nothing to
// fall back to and nothing to notice.
let motionCanReach = connection.motionReaches(declared: slot.pref)
if forwarding, let motion = c.motion, motion.hasRotationRate {
if motionCanReach {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
// Delivered on the MAIN queue, like every other handler here, and deliberately so
// even though ~250 Hz of samples on main is not free.
//
// GameController's `handlerQueue` is a property of the CONTROLLER, not of an
// element, so there is no way to move motion off main without moving buttons,
// sticks, the touchpad and the escape chord with it. This whole class is
// `@MainActor` eight `assumeIsolated` sites, the slot table, the gesture timers
// so that is a rewrite of the isolation model, not a queue assignment. It would
// also put the tvOS escape chord (the ONLY controller way out of a stream there)
// on a background queue, which is a real risk taken for a speculative gain.
//
// If main-queue contention ever shows up as motion jitter, the measurement to make
// first is `motion_cadence`'s per-pad inter-arrival histogram on the host it
// already reports exactly this, and would say whether the delay is here or on the
// wire before anyone restructures the class for it.
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
}
} else {
onMotionUnreachable?(slot.pref)
}
}
}
@@ -604,34 +642,70 @@ public final class GamepadCapture {
// would keep stomping the mirror's gyro with zeros.
if slot.pad == 0, deviceGyro?.isRunning == true { return }
#endif
let now = DispatchTime.now().uptimeNanoseconds
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
slot.lastMotionNs = now
// Total acceleration in g: gravity + user when split, else the raw vector.
// Every sample goes out. There used to be a 4 ms floor here, and it was a DROP: a sample
// arriving 3.9 ms after the last one was discarded outright.
//
// That is the wrong shape for this signal. Buttons and sticks are absolute state, so a
// dropped frame costs nothing the next one says everything it would have. Angular
// velocity is a RATE, and a consumer integrates it into an angle; a dropped sample is
// rotation that happened and can never be recovered. GameController's delivery is jittery
// around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra
// sample, it sheds a steady fraction of every turn and the error is one-signed, so it
// accumulates into aim drifting short rather than into noise.
//
// Nothing needed the ceiling: GC delivers at the sensor's rate rather than faster, the SDL
// client has always forwarded every sample, and the host's own idle watchdog runs on a
// 100 ms timeout this cannot outpace. The throttle's `lastMotionNs`/`motionIntervalNs` went
// with it rather than being left set-but-unread nothing else consumed either.
// Total acceleration in g: gravity + user when split, else the raw vector then NEGATED
// into the wire's convention.
//
// Apple reports acceleration as the gravity VECTOR: a device lying flat face-up reads
// z = 1, because gravity points down. An accelerometer physically measures proper
// acceleration, which at rest is the +1 g normal force pushing UP, and that is what a
// DualSense's report the wire's convention carries. The two are exact negatives, so
// every sample we sent was upside down, on both branches (`m.acceleration` follows the
// same Apple convention as the gravity/user split).
//
// Measured on glass 2026-08-07 (G16): a DualSense flat and face-up, streamed from an
// iPhone to a Linux host, arrived at hid-playstation as z = 0.99 g where +1.00 was owed.
// Magnitude was 1.006 g, so the SCALE was already right this is purely direction.
// `rotationRate` is a true angular rate and needs no flip; the same session confirmed yaw
// came through with the correct sign.
let ax: Float
let ay: Float
let az: Float
if m.hasGravityAndUserAcceleration {
ax = Float(m.gravity.x + m.userAcceleration.x)
ay = Float(m.gravity.y + m.userAcceleration.y)
az = Float(m.gravity.z + m.userAcceleration.z)
ax = -Float(m.gravity.x + m.userAcceleration.x)
ay = -Float(m.gravity.y + m.userAcceleration.y)
az = -Float(m.gravity.z + m.userAcceleration.z)
} else {
ax = Float(m.acceleration.x)
ay = Float(m.acceleration.y)
az = Float(m.acceleration.z)
ax = -Float(m.acceleration.x)
ay = -Float(m.acceleration.y)
az = -Float(m.acceleration.z)
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
// Into the DualSense report frame. GameController and the pad's own report do not agree
// about which slot is which axis measured, both from the same controller, on 2026-08-07
// so forwarding GC's x/y/z straight through sent yaw where the game reads roll. See
// `GamepadWire.appleMotionToWire`. One change of basis, applied to both planes.
let g = GamepadWire.appleMotionToWire(
(Float(m.rotationRate.x), Float(m.rotationRate.y), Float(m.rotationRate.z)))
let a = GamepadWire.appleMotionToWire((ax, ay, az))
let gyro = (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs)
GamepadWire.motionRaw(g.0, scale: gs),
GamepadWire.motionRaw(g.1, scale: gs),
GamepadWire.motionRaw(g.2, scale: gs)
)
let accel = (
GamepadWire.motionRaw(ax, scale: as_),
GamepadWire.motionRaw(ay, scale: as_),
GamepadWire.motionRaw(az, scale: as_)
GamepadWire.motionRaw(a.0, scale: as_),
GamepadWire.motionRaw(a.1, scale: as_),
GamepadWire.motionRaw(a.2, scale: as_)
)
// Recorded AFTER the frame conversion, deliberately: `flush` replays `lastAccel` beside a
// zero gyro, so it has to be the vector that actually went on the wire. Stashing the
// pre-conversion one would park a still pad's gravity in the wrong axis.
if wire != nil {
slot.motionSent = true
slot.lastAccel = accel
@@ -41,6 +41,10 @@ public final class GamepadManager: ObservableObject {
public let kind: PunktfunkConnection.GamepadType
public let hasLight: Bool
public let hasHaptics: Bool
/// This controller has a GYROSCOPE not merely a `GCMotion`. The distinction is the whole
/// point: an X-Box pad exposes a `GCMotion` that reports gravity and nothing else, so
/// `motion != nil` is true for a controller with no angular rate to give. Read
/// `hasRotationRate`, which is GameController's own answer to the question we mean.
public let hasMotion: Bool
public let hasAdaptiveTriggers: Bool
/// Specifically a DualSense (incl. the Edge same feedback surface) gates the
@@ -265,7 +269,10 @@ public final class GamepadManager: ObservableObject {
kind: kind,
hasLight: c.light != nil,
hasHaptics: c.haptics != nil,
hasMotion: c.motion != nil,
// `hasRotationRate`, not `motion != nil` see the property. The settings row shows a
// gyroscope badge off this, and promising a gyro an X-Box pad does not have is the
// same lie as streaming its non-existent rotation to the host.
hasMotion: c.motion?.hasRotationRate ?? false,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
// Edge included); the DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
@@ -73,6 +73,30 @@ public enum GamepadWire {
public static func motionRaw(_ value: Float, scale: Float) -> Int16 {
Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max)))
}
/// GameController's motion frame the DualSense report frame the wire is defined in.
///
/// The wire is a unit passthrough: the host writes these three components, in order, into the
/// virtual DualSense's report bytes 16../22.. the same slots a real pad fills. So the frame
/// the wire is defined in is the pad's OWN report frame, and a client that forwards its
/// platform's axes unconverted is simply speaking a different language.
///
/// Both frames were measured on 2026-08-07 from ONE physical DualSense on one desk the pad
/// read twice, over raw HID and through GameController:
///
/// DualSense report frame: (Right, Up, Backward) axis 0 carries pitch, 1 yaw, 2 roll
/// GameController frame: (Right, Forward, Up)
///
/// Matching them up: Right is already slot 0; Up is GC's z, so it moves to slot 1; and slot 2
/// wants Backward, which is GC's y negated. Hence `(x, z, -y)`.
///
/// Applied to gyro AND acceleration, because it is a change of basis and both are expressed in
/// that basis. The negation `forwardMotion` already does for acceleration is a separate matter
/// that one converts Apple's gravity-VECTOR convention into the proper acceleration a real
/// pad reports, and it composes with this rather than replacing it.
public static func appleMotionToWire(_ v: (Float, Float, Float)) -> (Float, Float, Float) {
(v.0, v.2, -v.1)
}
}
extension Float {
@@ -0,0 +1,91 @@
// The motion frame conversion, pinned against the readings it was derived from.
//
// On 2026-08-07 one physical DualSense was read twice on one desk over raw HID (the pad's own
// report) and through GameController so both frames come from the same controller in the same
// orientations rather than from two documents:
//
// DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll
// GameController frame: (Right, Forward, Up)
//
// The numbers below are those measurements. They are the reason the conversion is `(x, z, -y)` and
// not one of the five other permutations that also move gravity to slot 1, so they belong in a test
// rather than only in a commit message.
import XCTest
@testable import PunktfunkKit
final class GamepadMotionFrameTests: XCTestCase {
private func wire(_ v: (Float, Float, Float)) -> (Float, Float, Float) {
GamepadWire.appleMotionToWire(v)
}
/// Gravity at rest, face up. MEASURED: GameController read (+0.005, -0.192, +0.992) g while raw
/// HID on the same pad read (+0.021, +0.997, +0.160). The conversion has to carry one into the
/// other including the small tilt term, which is what distinguishes this mapping from the one
/// that merely gets gravity onto the right slot.
func testRestingGravityLandsInTheDualSenseFrame() {
let apple: (Float, Float, Float) = (0.005, -0.192, 0.992)
let w = wire(apple)
XCTAssertEqual(w.0, 0.005, accuracy: 0.001, "right stays on slot 0")
XCTAssertEqual(w.1, 0.992, accuracy: 0.001, "up moves to slot 1 — the pad reads +1 g here")
XCTAssertEqual(w.2, 0.192, accuracy: 0.001, "slot 2 is Backward, so GC's Forward negates")
// The hardware's own reading of the same pose, to the precision two sessions of holding a
// controller by hand can agree to.
XCTAssertEqual(w.1, 0.997, accuracy: 0.02)
XCTAssertEqual(w.2, 0.160, accuracy: 0.05)
}
/// The tilt term's SIGN is the whole point: before this conversion the client sent Apple's y
/// straight through, so a pad tilted nose-up reported itself tilted nose-down.
func testTheForeAftAxisIsNegatedNotJustMoved() {
XCTAssertEqual(wire((0, 1, 0)).2, -1, "GC +y (Forward) is the wire's -Backward")
XCTAssertEqual(wire((0, -1, 0)).2, 1)
XCTAssertEqual(wire((0, 1, 0)).0, 0, "and it must not leak into the other slots")
XCTAssertEqual(wire((0, 1, 0)).1, 0)
}
/// Each rotation, as measured, must reach the slot the wire reads it from: the wire's gyro is
/// documented pitch/yaw/roll in slots 0/1/2, and the raw-HID run confirmed the pad agrees.
func testEachRotationReachesItsWireSlot() {
// Yaw is the reliable direct measurement a continuous one-way spin, clockwise from above,
// read as NEGATIVE on GC's z. It must arrive negative on slot 1, where the pad puts yaw.
let yaw = wire((-0.2, 21.7, -122.2))
XCTAssertEqual(yaw.1, -122.2, accuracy: 0.01)
XCTAssertLessThan(yaw.1, 0, "clockwise-from-above is negative about +Up, both frames agree")
// Pitch: nose-down about Right stays on slot 0 and keeps its sign.
let pitch = wire((-79.4, 0, 0))
XCTAssertEqual(pitch.0, -79.4, accuracy: 0.01)
// Roll: about the fore-aft axis, which moves to slot 2 AND flips.
let roll = wire((0, 61.8, 0))
XCTAssertEqual(roll.2, -61.8, accuracy: 0.01)
}
/// A change of basis is linear and orthonormal: it may not stretch a vector, and applying it to
/// gyro and to acceleration must be the same operation. Both are asserted because the capture
/// path calls it twice, on two different quantities.
func testConversionIsAnIsometry() {
for v in [(1, 2, 3), (-4, 5, -6), (0, 0, 1), (7, 0, 0)] as [(Float, Float, Float)] {
let w = wire(v)
let before = (v.0 * v.0 + v.1 * v.1 + v.2 * v.2).squareRoot()
let after = (w.0 * w.0 + w.1 * w.1 + w.2 * w.2).squareRoot()
XCTAssertEqual(before, after, accuracy: 1e-4, "must not change magnitude")
}
}
/// Right-handed in, right-handed out. A permutation with the wrong number of sign flips is a
/// REFLECTION, which reads as plausible on every single axis and inverts every rotation the
/// exact failure this measurement exists to prevent.
func testHandednessIsPreserved() {
let x = wire((1, 0, 0))
let y = wire((0, 1, 0))
// x cross y must equal the image of z, not its negative.
let cx = (x.1 * y.2 - x.2 * y.1, x.2 * y.0 - x.0 * y.2, x.0 * y.1 - x.1 * y.0)
let z = wire((0, 0, 1))
XCTAssertEqual(cx.0, z.0, accuracy: 1e-5)
XCTAssertEqual(cx.1, z.1, accuracy: 1e-5)
XCTAssertEqual(cx.2, z.2, accuracy: 1e-5)
}
}
@@ -0,0 +1,62 @@
// Whether a given pad's motion can reach the game. The Swift half of punktfunk-core's
// `pad_motion_reaches` same rows as `config::tests::motion_reach_is_answered_per_pad_not_per_session`,
// because a client that disagrees with the host about this either kills a working gyro or keeps
// streaming ~250 Hz of samples nobody reads, and both failures are silent.
import PunktfunkCore
import XCTest
@testable import PunktfunkKit
final class GamepadMotionReachTests: XCTestCase {
private typealias Pad = PunktfunkConnection.GamepadType
func testOnlyTheXboxClassesLackAMotionPlane() {
for kind: Pad in [.xbox360, .xboxOne] {
XCTAssertFalse(kind.hasMotion, "\(kind) should have no motion plane")
}
for kind: Pad in [
.dualSense, .dualShock4, .dualSenseEdge, .switchPro,
.steamController, .steamDeck, .steamController2,
] {
XCTAssertTrue(kind.hasMotion, "\(kind) should carry motion")
}
// Unknown must not suppress: an older host that omitted the echo may well have resolved a
// DualSense, and silently killing its gyro is worse than sending into a void.
XCTAssertTrue(Pad.auto.hasMotion)
}
/// The per-pad question, case by case. Each row is a session a player can actually sit down to;
/// the comment says which of the three inputs decides it.
func testMotionReachIsAnsweredPerPadNotPerSession() {
// The case this predicate exists for, and the one a session-level check gets WRONG:
// "Automatic" with mixed pads. The handshake carries the active pad's kind (an X-Box pad),
// so the echo says X-Box 360 but pad 1 declared a DualSense and the host built it one,
// with a motion plane. Reading the echo here kills a gyro that works.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .xbox360, resolved: .xbox360))
// Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion.
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .xbox360, resolved: .xbox360))
// An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared ==
// asked, so the echo is this pad's answer and catches a fold nothing local could predict.
XCTAssertFalse(
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .xbox360))
// The same declaration against a Linux host that builds it: unchanged, motion reaches.
XCTAssertTrue(
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .switchPro))
// A DualSense wish on a host with no usable /dev/uhid degrades the same way.
XCTAssertFalse(
Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .xbox360))
// Nobody connected at dial time, so the handshake asked `.auto` and the host resolved it
// from its own env. A pad that shows up later declares its own kind and is judged on that.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .auto, resolved: .xbox360))
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .auto, resolved: .dualSense))
// An old host that echoes nothing leaves `.auto`, which must not suppress.
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .auto))
// Even then the declaration still speaks when it is the thing without a plane.
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .dualSense, resolved: .auto))
}
}
+2 -1
View File
@@ -1575,7 +1575,8 @@ pub fn show_scoped(
&dialog,
inline,
"Gamepad type",
"The virtual pad on the host — Automatic matches your controller",
"The virtual pad on the host — Automatic matches your controller. An X-Box type has no \
gyroscope, so pick a DualSense-class one if you want motion.",
&[
"Automatic",
"Xbox 360",
+5 -3
View File
@@ -1156,7 +1156,7 @@ async fn session(args: Args) -> Result<()> {
if args.rich_input_test {
let conn2 = conn.clone();
tokio::spawn(async move {
use punktfunk_core::input::gamepad::AXIS_LS_X;
use punktfunk_core::input::gamepad::{AXIS_LS_X, MOTION_ACCEL_LSB_PER_G};
use punktfunk_core::quic::RichInput;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// A neutral gamepad axis event makes the host create the virtual DualSense pad 0.
@@ -1184,12 +1184,14 @@ async fn session(args: Args) -> Result<()> {
for i in 0..60u32 {
let x = ((i * 65535) / 60) as u16;
let _ = conn2.send_datagram(touch(true, x, 32768).encode().into());
let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble
let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble, ±250 °/s
let _ = conn2.send_datagram(
RichInput::Motion {
pad: 0,
gyro: [g, 0, 0],
accel: [0, 0, 16384],
// At rest, gravity is 1 g on +Z — in WIRE units, which are 10000 LSB/g
// and not the 8192 or 16384 a particular driver happens to use.
accel: [0, 0, MOTION_ACCEL_LSB_PER_G as i16],
}
.encode()
.into(),
+72 -7
View File
@@ -39,11 +39,13 @@ use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`): the wire's
/// units ([`wire::MOTION_GYRO_LSB_PER_DEG_S`] / [`wire::MOTION_ACCEL_LSB_PER_G`]), which the host's
/// fixed calibration blobs declare back to their own consumers. SDL hands us gyro in rad/s and
/// accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 =
wire::MOTION_GYRO_LSB_PER_DEG_S as f32 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = wire::MOTION_ACCEL_LSB_PER_G as f32;
const G: f32 = 9.80665;
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
@@ -842,6 +844,12 @@ struct Slot {
/// Resolved controller kind (captured at open) — selects the Deck rumble keep-alive and the
/// DualSense raw-effect feedback path without re-querying SDL metadata under a `&mut` borrow.
pref: GamepadPref,
/// The kind this slot DECLARED to the host in its [`InputKind::GamepadArrival`]
/// ([`declared_kind`] of the setting and `pref`) — what the host actually built this pad from,
/// which under `Auto` differs per pad. Captured at open beside `pref` for the same reason, and
/// kept distinct from it because the two answer different questions: `pref` is the controller
/// in the user's hands (local feedback), this is the one the host is pretending to have.
declared: GamepadPref,
/// Wire axis state — zeroed on the wire when this slot closes (detach / unplug).
last_axis: [i32; 6],
held_buttons: Vec<u32>,
@@ -858,6 +866,13 @@ struct Slot {
/// close lift a click held across detach/unplug.
held_clicks: [bool; 2],
last_accel: [i16; 3],
/// This slot has put at least one motion sample on the wire, so the host is holding one.
/// Gates the zero-gyro park in [`Worker::flush_slot`] — a pad with no gyro must not start
/// looking like one just because it closed.
sent_motion: bool,
/// The "your gyro can't reach this session" notice fired for this slot (log once, not per
/// sample — this path runs at the pad's sensor rate).
motion_unreachable_logged: bool,
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
@@ -872,18 +887,27 @@ struct Slot {
}
impl Slot {
fn new(id: u32, index: u8, pref: GamepadPref, pad: sdl3::gamepad::Gamepad) -> Slot {
fn new(
id: u32,
index: u8,
pref: GamepadPref,
declared: GamepadPref,
pad: sdl3::gamepad::Gamepad,
) -> Slot {
Slot {
id,
index,
pad,
pref,
declared,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
surface_last: [(0, 0, false); 2],
held_clicks: [false; 2],
last_accel: [0; 3],
sent_motion: false,
motion_unreachable_logged: false,
gesture: SelectGesture::default(),
audio_caps: 0,
rumble_suppressed_logged: false,
@@ -1231,7 +1255,7 @@ impl Worker {
let declared = declared_kind(self.kind_override, pref);
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
let mut slot = Slot::new(id, index, pref, declared, pad);
Self::set_slot_sensors(&mut slot, true);
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
@@ -1480,6 +1504,19 @@ impl Worker {
};
let _ = c.send_rich_input(rich);
}
// Park motion. Gyro is level-triggered host-side — the last sample is preserved across
// button frames and re-emitted by the pad heartbeat — so a slot closing mid-rotation
// leaves the virtual pad turning, and a game integrating gyro aim turns with it. The host
// has an idle watchdog for the cases nobody can flush (a dropped link); this is the case
// we can, so take it immediately. Acceleration is kept: gravity doesn't stop when the
// session does.
if std::mem::take(&mut slot.sent_motion) {
let _ = c.send_rich_input(RichInput::Motion {
pad,
gyro: [0; 3],
accel: slot.last_accel,
});
}
}
/// True when any one forwarded pad holds the entire escape chord (any player can leave).
@@ -1988,10 +2025,38 @@ impl Worker {
}
}
SensorType::Gyroscope => {
// An X-Box class pad has no motion plane, so every sample below would be
// decoded and dropped. Say so once — the player's gyro is silently doing
// nothing and the fix is the controller-type setting — and stop paying to
// send ~250 Hz of them.
//
// Asked PER PAD, off this slot's own declaration. The session echo alone is
// the wrong question: under `Auto` the Hello carries the active pad's kind,
// so a couch with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360
// while the host builds pad 1 a DualSense with a working gyro.
if !punktfunk_core::config::pad_motion_reaches(
slot.declared,
c.requested_gamepad,
c.resolved_gamepad,
) {
if !slot.motion_unreachable_logged {
slot.motion_unreachable_logged = true;
tracing::warn!(
pad = slot.index,
declared = ?slot.declared,
resolved = ?c.resolved_gamepad,
"this controller has a gyro but the host built it a backend \
without one motion will not reach the game; pick a \
DualSense-class controller type to get it"
);
}
return;
}
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
slot.sent_motion = true;
let _ = c.send_rich_input(RichInput::Motion {
pad: slot.index,
gyro,
+24 -1
View File
@@ -1246,7 +1246,25 @@ pub mod gamepad {
/// a pre-v2.2 driver that never writes it = [`OUT_RING_LEN`]. Carved from v2.1 reserved
/// space (v2.2).
pub out_ring_len: u32,
pub _reserved1: [u8; 88],
/// Seqlock generation over the [`PadShm::input`] slot (host-written): **odd** while a
/// report is mid-copy, **even** when the slot holds a whole one. The host bumps it to odd,
/// `Release`-fences, writes the 64 bytes, then `Release`-stores it even; a driver samples
/// it before and after its read and retries when it caught a write in flight.
///
/// The input slot is a single unqueued buffer that both sides touch without a lock, so a
/// driver read landing mid-copy hands the game a report that is half the previous frame
/// and half the next. For buttons that is a one-tick glitch; for motion it is a spike in
/// angular velocity, and anything integrating gyro aim turns a spike into real aim
/// movement. (v2.3)
///
/// Version posture, same as the ring's: an old driver never reads this and behaves exactly
/// as it does today, and against an old HOST the field stays 0 — a constant even value, so
/// a new driver's re-check always passes and it, too, behaves exactly as today. No
/// capability stamp is needed because "never written" and "no write in flight" are the
/// same observation. Carved from v2.2 reserved space, inside the v2 legacy region so even
/// the smallest cross-generation map covers it.
pub input_gen: u32,
pub _reserved1: [u8; 84],
/// The lossless output-report ring — [`OUT_RING_LEN`] slots under a v2.1 negotiation,
/// [`OUT_RING_LEN_V22`] under v2.2 (slots 8.. overlay what v2.1 called `_reserved2`,
/// which no shipped binary ever read or wrote). See the struct docs and [`OutSlot`].
@@ -1295,6 +1313,11 @@ pub mod gamepad {
// stays within the v2.1 slots' historical offsets (slot k at 256 + k*68), and the whole
// struct is exactly the one page that keeps cross-generation views mappable.
assert!(offset_of!(PadShm, out_ring_len) == 164);
// v2.3 input seqlock — 4-aligned (the atomic accessors check it) and inside the v2 legacy
// region, so every driver generation's map covers it whether or not it reads it.
assert!(offset_of!(PadShm, input_gen) == 168);
assert!(offset_of!(PadShm, input_gen) % 4 == 0);
assert!(offset_of!(PadShm, input_gen) < PAD_SHM_LEGACY_SIZE);
assert!(
PAD_SHM_LEGACY_SIZE + OUT_RING_LEN_USIZE * size_of::<OutSlot>() <= PAD_SHM_V21_SIZE
);
+26 -4
View File
@@ -17,6 +17,7 @@ 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::sensor_clock::SensorClock;
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,
@@ -28,6 +29,7 @@ use punktfunk_core::quic::RichInput;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::time::Instant;
/// 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
@@ -71,7 +73,7 @@ impl DsUhidIdentity {
pub struct DualSensePad {
fd: File,
seq: u8,
ts: u32,
clock: SensorClock,
}
impl DualSensePad {
@@ -86,7 +88,11 @@ impl DualSensePad {
.with_context(|| {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?;
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
let mut ds = DualSensePad {
fd,
seq: 0,
clock: SensorClock::dualsense(),
};
ds.send_create2(index, id)
.context("UHID_CREATE2 DualSense")?;
Ok(ds)
@@ -116,9 +122,9 @@ impl DualSensePad {
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
self.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs
let ts = self.clock.ds_ticks(Instant::now());
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
serialize_state(&mut r, st, self.seq, ts);
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
@@ -275,6 +281,14 @@ impl PadProto for DsLinuxProto {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let _ = pad.write_state(st);
}
@@ -368,6 +382,14 @@ impl PadProto for DsEdgeLinuxProto {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let _ = pad.write_state(st);
}
+21 -63
View File
@@ -9,15 +9,17 @@
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the
//! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure
//! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the
//! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs.
//! report codec (input `0x01` serializer, output `0x05` parser, touch dims, and the feature blobs
//! the kernel GET_REPORTs) is the pure [`super::dualshock4_proto`], shared with the Windows UMDF
//! backend — this module is only the `/dev/uhid` transport plus the report descriptor and the
//! handshake that answers those GET_REPORTs.
use super::dualsense_proto::DsState;
use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
DS4_TOUCH_W, DS4_VENDOR,
ds4_pairing_reply, parse_ds4_output, serialize_state, Ds4Feedback, DS4_FEATURE_CALIBRATION,
DS4_FEATURE_FIRMWARE, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, DS4_TOUCH_W, DS4_VENDOR,
};
use crate::sensor_clock::SensorClock;
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,
@@ -29,60 +31,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// 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
// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for
// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it).
#[rustfmt::skip]
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
/// The pairing reply for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low octet offset
/// by the pad index — same per-pad-serial contract as the DualSense's
/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as
/// the HID uniq, and SDL/Steam dedup controllers by that serial.
fn ds4_pairing_reply(pad: u8) -> [u8; 16] {
let mut r = [0u8; 16];
r.copy_from_slice(DS4_FEATURE_PAIRING);
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
r
}
#[rustfmt::skip]
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
0x02,
0x00, 0x00, // gyro_pitch_bias = 0
0x00, 0x00, // gyro_yaw_bias = 0
0x00, 0x00, // gyro_roll_bias = 0
0x10, 0x00, // gyro_pitch_plus = +16
0xF0, 0xFF, // gyro_pitch_minus = -16
0x10, 0x00, // gyro_yaw_plus = +16
0xF0, 0xFF, // gyro_yaw_minus = -16
0x10, 0x00, // gyro_roll_plus = +16
0xF0, 0xFF, // gyro_roll_minus = -16
0x20, 0x00, // gyro_speed_plus = +32
0x20, 0x00, // gyro_speed_minus = +32
0x00, 0x20, // acc_x_plus = +8192
0x00, 0xE0, // acc_x_minus = -8192
0x00, 0x20, // acc_y_plus = +8192
0x00, 0xE0, // acc_y_minus = -8192
0x00, 0x20, // acc_z_plus = +8192
0x00, 0xE0, // acc_z_minus = -8192
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
];
#[rustfmt::skip]
const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
0x00, 0x00, 0x00, 0x00, 0x00,
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
0x00, 0x00, 0x00, 0x00,
0x00, 0x01, // fw_version = 0x0100 (buf[41])
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
];
use std::time::Instant;
/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture
/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature
@@ -140,7 +89,7 @@ const DS4_RDESC: &[u8] = &[
pub struct DualShock4Pad {
fd: File,
counter: u8,
ts: u16,
clock: SensorClock,
}
impl DualShock4Pad {
@@ -157,7 +106,7 @@ impl DualShock4Pad {
let mut ds = DualShock4Pad {
fd,
counter: 0,
ts: 0,
clock: SensorClock::dualshock4(),
};
ds.send_create2(index).context("UHID_CREATE2 DualShock4")?;
Ok(ds)
@@ -187,9 +136,9 @@ impl DualShock4Pad {
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
self.counter = self.counter.wrapping_add(1);
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
let ts = self.clock.ds4_ticks(Instant::now());
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.counter, self.ts);
serialize_state(&mut r, st, self.counter, ts);
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
@@ -346,6 +295,14 @@ impl PadProto for Ds4LinuxProto {
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
let _ = pad.write_state(st);
}
@@ -383,6 +340,7 @@ pub type DualShock4Manager = UhidManager<Ds4LinuxProto>;
#[cfg(test)]
mod tests {
use super::*;
use crate::dualshock4_proto::DS4_FEATURE_PAIRING;
// The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec
// is shared with the Windows backend); only the UHID-transport-specific pieces are tested here.
@@ -422,6 +422,14 @@ impl PadProto for SteamProto {
st.apply_rich(rich);
}
fn neutralize_gyro(&self, st: &mut SteamState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut SteamState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) {
pad.write_state(st);
}
@@ -544,6 +552,14 @@ impl PadProto for ScProto {
st.apply_rich(rich);
}
fn neutralize_gyro(&self, st: &mut SteamState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut SteamState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) {
let _ = pad.write_state(st);
}
@@ -346,6 +346,12 @@ impl PadProto for TritonProto {
// and the synth fallback has no surface for them.
}
// `neutralize_gyro` / `clear_rich` stay the no-op defaults: this backend never sees a
// `RichInput::Motion` to go stale, and its motion lives inside an opaque passthrough report
// whose bytes we would have to reach into blind. A raw feed that stops is the client's own
// device report stopping, so the same last-report re-emission applies here — worth revisiting
// if SC2 gyro ever shows the phantom-rotation signature the DualSense family had.
fn write_state(&self, pad: &mut TritonTransport, st: &TritonState) {
pad.write_state(st);
}
@@ -288,6 +288,14 @@ impl PadProto for SwitchProProto {
}
}
fn neutralize_gyro(&self, st: &mut SwitchState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut SwitchState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) {
let _ = pad.write_state(st);
}
@@ -472,7 +472,13 @@ fn build_triton_device(
address: addr,
attributes: 0x03, // interrupt
max_packet_size: 64, // wMaxPacketSize 0x0040
interval: 1, // bInterval 1 — the real pad's 1 kHz
// bInterval 1 — the real pad's 1 kHz. ⚠ Do NOT "fix" this to 4: bInterval is only the
// 2^(n-1) × 125 µs exponent on a HIGH-speed device, and this one negotiates FULL speed
// (`dev.speed` below), where the field is a plain frame count in milliseconds. So 1 means
// 1 ms = 1 kHz, exactly as intended, and 4 would mean 4 ms = 250 Hz — a 4× cut to the
// motion rate a passed-through SC2 delivers. (A 2026-08-07 sweep read this as high-speed
// and called it an 8 kHz duplicate storm; it is neither.)
interval: 1,
};
let mut dev = UsbDevice::new(0);
dev.vendor_id = TRITON_VENDOR;
+83 -17
View File
@@ -16,6 +16,22 @@ const _: () = assert!(MAX_PADS <= 16);
/// quiet.
const SWEEP_GRACE: Duration = Duration::from_millis(300);
/// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct Sweep {
/// Slots whose pad was torn down because its grace ran out — the caller resets their
/// per-index sibling state.
pub dropped: u16,
/// Slots whose `active_mask` bit returned *inside* the grace window. The debounce did its job
/// and no devnode flapped — but the pad that comes back is not necessarily the pad that left:
/// unplug one controller and plug another into the same wire index within [`SWEEP_GRACE`] and
/// the new one drives the old one's live virtual pad, skipping the create path (and therefore
/// the manager's reset) entirely. Anything the manager persists on the client's behalf —
/// touch contacts, motion — is the previous controller's and has to go; a pad with no gyro
/// would otherwise inherit the last one's rotation and never send a sample to correct it.
pub reclaimed: u16,
}
/// The slot table + lifecycle every virtual-pad manager repeats: `Vec<Option<P>>` keyed by wire pad
/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim
/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times.
@@ -63,16 +79,16 @@ impl<P> PadSlots<P> {
}
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
/// frames and returns re-arms nothing.
/// (see [`Self::reap`]). Returns what changed so the caller can fix up its per-index sibling
/// state; an index another manager owns is `None` here, so it is never touched. The grace is
/// the devnode-churn debounce: a mask that glitches clear for a few frames and returns re-arms
/// nothing.
///
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
/// called `sweep` would keep the detached pad alive for the rest of the session.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
pub fn sweep(&mut self, active_mask: u16) -> Sweep {
self.sweep_at(active_mask, Instant::now())
}
@@ -98,15 +114,24 @@ impl<P> PadSlots<P> {
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
/// each slot's clock from the mask, then reap whatever has already run out.
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> Sweep {
let mut reclaimed = 0u16;
for i in 0..MAX_PADS {
if active_mask & (1 << i) != 0 {
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
// Active (again): a glitch never reaches the drop. If a clock WAS armed, this slot
// just handed a live pad to whatever controller is present now, without passing
// through `ensure` — see `Sweep::reclaimed`.
if self.inactive_since[i].take().is_some() {
reclaimed |= 1 << i;
}
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
self.inactive_since[i] = Some(now); // newly inactive — start the grace
}
}
self.reap_at(now)
Sweep {
dropped: self.reap_at(now),
reclaimed,
}
}
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
@@ -195,7 +220,7 @@ mod tests {
assert!(s.ensure(2, |i| Ok(i as u32)));
assert_eq!(
s.sweep(0b0),
0,
Sweep::default(),
"a frame arms the grace but cannot itself drop"
);
assert!(s.get(2).is_some());
@@ -227,16 +252,35 @@ mod tests {
// comes back must not churn a PnP devnode.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
assert_eq!(s.sweep(0b0), Sweep::default()); // bit clears — arms only
for _ in 0..5 {
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
}
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
// The bit returns — disarms, and reports the re-claim: the pad survived, but whoever is
// driving it now may not be the controller that armed the clock.
assert_eq!(
s.sweep(0b1),
Sweep {
dropped: 0,
reclaimed: 1,
}
);
s.expire_grace();
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
assert!(s.get(0).is_some());
}
#[test]
fn a_mask_that_never_went_clear_is_not_a_reclaim() {
// `reclaimed` must mean "came back inside the grace", not "is present" — a steady-state
// frame stream would otherwise clear the client's touch and motion on every single frame.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
for _ in 0..5 {
assert_eq!(s.sweep(0b1), Sweep::default());
}
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
@@ -259,16 +303,25 @@ mod tests {
// Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events. The
// first sweep only ARMS the grace clock…
let t0 = Instant::now();
assert_eq!(s.sweep_at(0b0000_0100, t0), 0);
assert_eq!(s.sweep_at(0b0000_0100, t0), Sweep::default());
assert_eq!(s.get(0), Some(&0), "still inside the grace");
// …and the drop lands once the bits have stayed clear for the whole grace.
let swept = s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE);
assert_eq!(swept, 0b0010_0001);
assert_eq!(
swept,
Sweep {
dropped: 0b0010_0001,
reclaimed: 0,
}
);
assert_eq!(s.get(0), None);
assert_eq!(s.get(2), Some(&2));
assert_eq!(s.get(5), None);
// A further identical sweep is a no-op: the indices were returned exactly once.
assert_eq!(s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE * 2), 0);
assert_eq!(
s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE * 2),
Sweep::default()
);
}
#[test]
@@ -278,9 +331,22 @@ mod tests {
let mut s = slots();
assert!(s.ensure(1, |_| Ok(7)));
let t0 = Instant::now();
assert_eq!(s.sweep_at(0, t0), 0); // bit clears — grace armed
assert_eq!(s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE / 2), 0); // bit returns — disarmed
assert_eq!(s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE * 10), 0);
// The bit clears — grace armed.
assert_eq!(s.sweep_at(0, t0), Sweep::default());
// The bit returns: disarmed, and reported as a re-claim (the pad lives, but its owner may
// have changed — see `Sweep::reclaimed`).
assert_eq!(
s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE / 2),
Sweep {
dropped: 0,
reclaimed: 0b0000_0010,
}
);
// …and once, not on every subsequent frame.
assert_eq!(
s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE * 10),
Sweep::default()
);
assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop");
}
@@ -12,6 +12,7 @@
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
//! same as a real USB DualSense.
use punktfunk_core::input::gamepad as gs;
use punktfunk_core::quic::{HidOutput, RichInput};
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
@@ -222,7 +223,15 @@ pub struct DsState {
}
impl DsState {
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral) — and, crucially, a pad that
/// is sitting STILL rather than falling.
///
/// Acceleration is 1 g up ([`gs::MOTION_NEUTRAL_ACCEL`]), not zero. `[0, 0, 0]` reads as free
/// fall to anything that interprets the accelerometer, which is a definite lie about the
/// physical world; a pad that has sent no motion yet — or has none at all — is on a desk or in
/// someone's hands, and both read 1 g up. This is what `switch_proto`'s neutral has always done
/// on its own up axis, and the DualSense family now does on the axis a real DualSense was
/// measured to use. The DS4 reuses this state, so it is covered by the same line.
pub fn neutral() -> DsState {
DsState {
lx: 0x80,
@@ -230,10 +239,31 @@ impl DsState {
rx: 0x80,
ry: 0x80,
dpad: 8,
accel: gs::MOTION_NEUTRAL_ACCEL,
..Default::default()
}
}
/// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and
/// everything else. Returns whether anything changed — the host's idle-motion watchdog,
/// `PadProto::neutralize_gyro`.
pub fn neutralize_gyro(&mut self) -> bool {
let changed = self.gyro != [0; 3];
self.gyro = [0; 3];
changed
}
/// Reset the rich-plane fields — touch contacts, pad clicks, motion — to a fresh pad's,
/// leaving buttons/sticks/triggers alone. `PadProto::clear_rich`: a controller that took over
/// this slot inside the replug grace must not inherit the last one's finger or rotation.
pub fn clear_rich(&mut self) {
let fresh = DsState::neutral();
self.touch = fresh.touch;
self.touch_click = fresh.touch_click;
self.gyro = fresh.gyro;
self.accel = fresh.accel;
}
/// Map a GameStream/XInput pad frame (button bitmask + i16 sticks + u8 triggers) into the
/// DualSense report fields. Sticks are recentred to `0x80`; the Y axes are inverted (XInput
/// `+y = up`, DualSense `0 = up`). Triggers double as the L2/R2 buttons when pressed. Touchpad
@@ -2,12 +2,13 @@
//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
//! ([`super::dualshock4`]).
//!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
//! shared-memory channel — both build/parse the exact same bytes here.
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec and the fixed feature
//! blobs, with no transport. The DS4 reuses the DualSense [`DsState`] controller model + its
//! `GameStream`/XInput mapper ([`DsState::from_gamepad`]) — only the report *byte layout*, the
//! touchpad resolution, and the feedback report differ. The Linux backend writes report `0x01` to
//! `/dev/uhid` and reads `0x05` via `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF
//! driver and pulls `0x05` back over its shared-memory channel — both build/parse the exact same
//! bytes here.
//!
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
//! dualshock4_input_report_usb` / `_output_report_common` parse.
@@ -24,6 +25,82 @@ pub const DS4_INPUT_REPORT_LEN: usize = 64;
pub const DS4_TOUCH_W: u16 = 1920;
pub const DS4_TOUCH_H: u16 = 942;
// Feature reports the host stack GET_REPORTs during DS4 init, the PS4 counterpart of
// `dualsense_proto`'s DS_FEATURE_* blobs. PAIRING (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 non-fatal (the kernel warns and falls
// back to identity IMU calibration), but we answer them so motion scales correctly. Each array's
// first byte is the report id (the kernel hard-checks it).
#[rustfmt::skip]
pub const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
/// IMU calibration (report `0x02`) — the numbers that decide what a *degree per second* means to
/// every consumer of this pad.
///
/// A consumer (kernel `hid-playstation`, SDL's `SDL_hidapi_ps4`) derives its scale from this blob,
/// it does not assume one: gyro resolution = `(|pitch_plus| + |pitch_minus|) / (speed_plus +
/// speed_minus)` LSB per °/s, accel resolution = `(acc_plus - acc_minus) / 2` LSB per g. So the
/// blob is where the wire contract
/// ([`MOTION_GYRO_LSB_PER_DEG_S`](punktfunk_core::input::gamepad::MOTION_GYRO_LSB_PER_DEG_S)) is
/// *declared* on this backend, and it must state exactly what the wire delivers. These values are
/// the DualSense blob's, which is the same statement in the same units — deliberately, since both
/// pads consume the identical wire sample.
///
/// ⚠ The per-axis order is INTERLEAVED (`pitch±`, `yaw±`, `roll±`), which is the **USB** layout;
/// Bluetooth groups all three plusses first. Our virtual pad declares `BUS_USB`, so interleaved is
/// correct — do not "fix" it to grouped.
///
/// The Windows UMDF driver serves its own copy of this blob
/// (`packaging/windows/drivers/pf-gamepad/src/lib.rs`) because it lives in a separate WDK
/// workspace and cannot depend on this crate; the `motion_contract` test derives the units from
/// *that* file's source too, so the two can't drift.
#[rustfmt::skip]
pub const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
0x02,
0x00, 0x00, // gyro_pitch_bias = 0
0x00, 0x00, // gyro_yaw_bias = 0
0x00, 0x00, // gyro_roll_bias = 0
0x10, 0x27, // gyro_pitch_plus = +10000
0xF0, 0xD8, // gyro_pitch_minus = -10000
0x10, 0x27, // gyro_yaw_plus = +10000
0xF0, 0xD8, // gyro_yaw_minus = -10000
0x10, 0x27, // gyro_roll_plus = +10000
0xF0, 0xD8, // gyro_roll_minus = -10000
0xF4, 0x01, // gyro_speed_plus = +500 ⇒ 20000/1000 = 20 LSB per °/s
0xF4, 0x01, // gyro_speed_minus = +500
0x10, 0x27, // acc_x_plus = +10000 ⇒ 20000/2 = 10000 LSB per g
0xF0, 0xD8, // acc_x_minus = -10000
0x10, 0x27, // acc_y_plus = +10000
0xF0, 0xD8, // acc_y_minus = -10000
0x10, 0x27, // acc_z_plus = +10000
0xF0, 0xD8, // acc_z_minus = -10000
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
];
#[rustfmt::skip]
pub const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
0x00, 0x00, 0x00, 0x00, 0x00,
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
0x00, 0x00, 0x00, 0x00,
0x00, 0x01, // fw_version = 0x0100 (buf[41])
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
];
/// The pairing reply (report `0x12`) for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low
/// octet offset by the pad index — same per-pad-serial contract as the DualSense's
/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as the
/// HID uniq, and SDL/Steam dedup controllers by that serial.
pub fn ds4_pairing_reply(pad: u8) -> [u8; 16] {
let mut r = [0u8; 16];
r.copy_from_slice(DS4_FEATURE_PAIRING);
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
r
}
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
fn pack_touch(dst: &mut [u8], t: &Touch) {
@@ -168,8 +168,46 @@ pub struct SteamState {
}
impl SteamState {
/// A fresh pad — and one that is sitting STILL, not falling.
///
/// Acceleration is 1 g up, for the reason spelled out on [`gs::MOTION_NEUTRAL_ACCEL`]: zero is
/// free fall, which is a claim about the world that is never true of a controller. It is put
/// through [`super::steam_remap::motion_wire_to_deck`] rather than written out in Deck units,
/// so the neutral and every real sample can never disagree about what 1 g is — the Deck's
/// `hid-steam` resolution lives in exactly one place.
pub fn neutral() -> SteamState {
SteamState::default()
let (_, accel) = super::steam_remap::motion_wire_to_deck([0; 3], gs::MOTION_NEUTRAL_ACCEL);
SteamState {
accel,
..SteamState::default()
}
}
/// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and
/// everything else. Returns whether anything changed — the host's idle-motion watchdog,
/// `PadProto::neutralize_gyro`.
pub fn neutralize_gyro(&mut self) -> bool {
let changed = self.gyro != [0; 3];
self.gyro = [0; 3];
changed
}
/// Reset the rich-plane fields — both trackpads' position/pressure/click, and motion — to a
/// fresh pad's, leaving buttons/sticks/triggers alone. `PadProto::clear_rich`: a controller
/// that took over this slot inside the replug grace must not inherit the last one's finger or
/// rotation.
pub fn clear_rich(&mut self) {
let fresh = SteamState::neutral();
self.lpad_x = fresh.lpad_x;
self.lpad_y = fresh.lpad_y;
self.rpad_x = fresh.rpad_x;
self.rpad_y = fresh.rpad_y;
self.lpad_pressure = fresh.lpad_pressure;
self.rpad_pressure = fresh.rpad_pressure;
self.lpad_click = fresh.lpad_click;
self.rpad_click = fresh.rpad_click;
self.gyro = fresh.gyro;
self.accel = fresh.accel;
}
/// Set/clear a button (or group) by its [`btn`] mask.
@@ -76,13 +76,15 @@ pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
buttons
}
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
// Motion rescale. The wire uses the DualSense convention (`gs::MOTION_*` — the scale every client
// capture applies); the Steam Deck's `hid-steam` fixes STEAM_DECK_GYRO_RES_PER_DPS = 16 and
// STEAM_DECK_ACCEL_RES_PER_G = 16384, so the Deck backend rescales. The DualSense / DS4 backends
// consume the wire 1:1 instead, because their calibration blobs declare the wire's own units.
// pf-inject's `motion_contract` test pins both halves of that sentence.
const GYRO_NUM: i32 = 16;
const GYRO_DEN: i32 = 20;
const GYRO_DEN: i32 = gs::MOTION_GYRO_LSB_PER_DEG_S;
const ACCEL_NUM: i32 = 16384;
const ACCEL_DEN: i32 = 10000;
const ACCEL_DEN: i32 = gs::MOTION_ACCEL_LSB_PER_G;
fn scale(v: i16, num: i32, den: i32) -> i16 {
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
@@ -37,6 +37,14 @@ use punktfunk_core::input::gamepad as gs;
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
/// The raw IMU resolutions `hid-nintendo` reports a Pro Controller at — its own
/// `JC_IMU_GYRO_RES_PER_DPS` (14.247, carried here in thousandths so the ratio stays exact) and
/// `JC_IMU_ACCEL_RES_PER_G`. Fixed by the driver, not by us: the factory-calibration blob we serve
/// is the driver's identity default, so it consumes our report at exactly these numbers.
const JC_IMU_GYRO_MILLI_RES_PER_DPS: i32 = 14_247;
/// See [`JC_IMU_GYRO_MILLI_RES_PER_DPS`].
const JC_IMU_ACCEL_RES_PER_G: i32 = 4096;
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
@@ -211,13 +219,32 @@ impl SwitchState {
}
}
/// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and
/// everything else. Returns whether anything changed — the host's idle-motion watchdog,
/// `PadProto::neutralize_gyro`.
pub fn neutralize_gyro(&mut self) -> bool {
let changed = self.gyro != [0; 3];
self.gyro = [0; 3];
changed
}
/// Reset the rich-plane fields to a fresh pad's, leaving buttons/sticks alone — for the Pro
/// Controller that is motion only (it has no touchpad). `PadProto::clear_rich`.
pub fn clear_rich(&mut self) {
let fresh = SwitchState::neutral();
self.gyro = fresh.gyro;
self.accel = fresh.accel;
}
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
// Wire units → the driver's raw units. Gyro is carried in thousandths so 14.247 stays exact.
let gyro_den = 1000 * gs::MOTION_GYRO_LSB_PER_DEG_S;
self.gyro = gyro.map(|v| ((v as i32 * JC_IMU_GYRO_MILLI_RES_PER_DPS) / gyro_den) as i16);
self.accel = accel
.map(|v| ((v as i32 * JC_IMU_ACCEL_RES_PER_G) / gs::MOTION_ACCEL_LSB_PER_G) as i16);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! The `sensor_timestamp` a virtual Sony pad stamps into every input report.
//!
//! Real hardware fills this field from its IMU's own clock, and it is the **only** time basis a
//! consumer has for the motion samples in the same report: `hid-playstation` forwards it as
//! `MSC_TIMESTAMP`, SDL reads it straight out of the report on Windows, and anything doing gyro aim
//! integrates angular velocity against the `dt` it implies. A clock in the wrong units doesn't look
//! broken — it looks like a controller whose sensitivity is off by that factor.
//!
//! Our virtual pads used to advance the field by a fixed amount per report: the DualSense by +1 raw
//! unit (0.33 µs, a clock running ~12000× slow — effectively frozen), the DualShock 4 by +188
//! (~1 ms) regardless of the real 48 ms publish cadence. Both now stamp real elapsed time.
//!
//! The value is computed from the pad's first report rather than accumulated per report, so a
//! bursty or throttled publish loop cannot make the clock drift; the caller truncates it to the
//! field's width, which reproduces the wrap real hardware does (and which every consumer's
//! `prev > current` delta check already handles).
use std::time::Instant;
/// A monotonic sensor clock in one pad's tick units. Construct per pad — the epoch is that pad's
/// first report, so the field starts at 0 like a freshly enumerated device.
pub struct SensorClock {
epoch: Option<Instant>,
/// Ticks per microsecond as an exact fraction, `ticks_num / ticks_den`.
ticks_num: u64,
ticks_den: u64,
}
impl SensorClock {
/// DualSense: the u32 `sensor_timestamp` counts **1/3 µs** ticks — `hid-playstation` converts a
/// delta with `DIV_ROUND_CLOSEST(delta, 3)`. Wraps every ~23.9 minutes.
pub fn dualsense() -> SensorClock {
SensorClock::new(3, 1)
}
/// DualShock 4: the u16 `sensor_timestamp` counts **16/3 µs** (≈5.33 µs) ticks —
/// `DIV_ROUND_CLOSEST(delta * 16, 3)`. Wraps every ~349 ms, which is normal and expected.
pub fn dualshock4() -> SensorClock {
SensorClock::new(3, 16)
}
fn new(ticks_num: u64, ticks_den: u64) -> SensorClock {
SensorClock {
epoch: None,
ticks_num,
ticks_den,
}
}
/// Ticks elapsed since this pad's first report. `now` is a parameter rather than an internal
/// `Instant::now()` so the unit tests below can drive the clock.
pub fn ticks(&mut self, now: Instant) -> u64 {
let epoch = *self.epoch.get_or_insert(now);
let micros = now.saturating_duration_since(epoch).as_micros() as u64;
micros * self.ticks_num / self.ticks_den
}
/// [`ticks`](Self::ticks) truncated to the DualSense's u32 field.
pub fn ds_ticks(&mut self, now: Instant) -> u32 {
self.ticks(now) as u32
}
/// [`ticks`](Self::ticks) truncated to the DualShock 4's u16 field.
pub fn ds4_ticks(&mut self, now: Instant) -> u16 {
self.ticks(now) as u16
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// One second of elapsed time must read as one second in each pad's units — the property the
/// old fixed-increment clocks got wrong by 12000× (DualSense) and ~5× (DualShock 4).
#[test]
fn a_second_reads_as_a_second() {
let t0 = Instant::now();
let after = t0 + Duration::from_secs(1);
// DualSense: 1 s = 3_000_000 ticks of 1/3 µs.
let mut ds = SensorClock::dualsense();
assert_eq!(ds.ticks(t0), 0, "the first report is the epoch");
assert_eq!(ds.ticks(after), 3_000_000);
// DualShock 4: 1 s = 187_500 ticks of 16/3 µs.
let mut ds4 = SensorClock::dualshock4();
assert_eq!(ds4.ticks(t0), 0);
assert_eq!(ds4.ticks(after), 187_500);
}
/// A realistic 4 ms publish interval, which is what the DS4's old `+188` claimed to be (it was
/// ~1 ms) and what the DualSense's old `+1` was off by four orders of magnitude from.
#[test]
fn one_publish_interval() {
let t0 = Instant::now();
let mut ds = SensorClock::dualsense();
let mut ds4 = SensorClock::dualshock4();
ds.ticks(t0);
ds4.ticks(t0);
let after = t0 + Duration::from_millis(4);
assert_eq!(ds.ticks(after), 12_000); // 4000 µs × 3
assert_eq!(ds4.ticks(after), 750); // 4000 µs × 3 / 16
}
/// The value is anchored to the epoch, not accumulated — an irregular cadence stays honest.
#[test]
fn jitter_does_not_drift() {
let t0 = Instant::now();
let mut ds4 = SensorClock::dualshock4();
ds4.ticks(t0); // the pad's first report — this, not `t0` itself, is the epoch
let mut t = t0;
for step in [1u64, 17, 3, 40, 9, 2] {
t += Duration::from_millis(step);
ds4.ticks(t);
}
// 72 ms since that first report, regardless of how it was walked.
assert_eq!(ds4.ticks(t), 72_000 * 3 / 16);
}
/// Both fields wrap, exactly as the hardware's do; consumers handle it with a `prev > current`
/// check, so truncation is the correct way to fill them.
#[test]
fn fields_wrap_like_hardware() {
let t0 = Instant::now();
// The DS4's u16 holds 65536 ticks × 16/3 µs = 349_525.33 µs, so 349_525 µs is still the
// last representable tick and the next microsecond rolls over.
let mut ds4 = SensorClock::dualshock4();
ds4.ticks(t0);
assert_eq!(ds4.ds4_ticks(t0 + Duration::from_micros(349_525)), 65_535);
assert_eq!(ds4.ds4_ticks(t0 + Duration::from_micros(349_526)), 0);
// The DualSense's u32 takes ~23.9 minutes to get there; 3 ticks per µs means the first
// microsecond past the roll lands on 2.
let mut ds = SensorClock::dualsense();
ds.ticks(t0);
let past_wrap = Duration::from_micros(u32::MAX as u64 / 3 + 1);
assert_eq!(ds.ds_ticks(t0 + past_wrap), 2);
}
}
+191 -8
View File
@@ -84,6 +84,30 @@ pub trait PadProto {
fn force_heartbeat(&self, _pad: &Self::Pad) -> bool {
false
}
/// Zero this state's **angular velocity**, keeping everything else — acceleration included.
/// Returns whether anything actually changed, so a pad already at rest costs no write.
///
/// Motion is a level-triggered plane: [`merge_frame`](Self::merge_frame) preserves the last
/// sample and the heartbeat re-emits it with a fresh sequence, so a client that stops sending
/// Motion — backgrounded app, a suspended session, a controller swapped for one with no gyro —
/// leaves the virtual pad reporting a constant rotation that anything integrating gyro aim
/// will happily spin on forever. Rumble and the pen plane each have an idle watchdog; this is
/// motion's, driven from [`MOTION_IDLE_TIMEOUT`].
///
/// Acceleration is deliberately NOT zeroed: gravity is legitimately persistent, so a still pad
/// reporting 1 g down stays correct while a still pad reporting 200 °/s does not.
///
/// Backends with no motion plane leave this a no-op and never take the extra write.
fn neutralize_gyro(&self, _st: &mut Self::State) -> bool {
false
}
/// Reset the rich-plane fields (touchpad contacts + motion) to what a fresh pad carries,
/// leaving buttons, sticks and every feedback cursor alone — for the replug-grace re-claim
/// ([`Sweep::reclaimed`](crate::pad_slots::Sweep::reclaimed)), where a different controller
/// inherits a live virtual pad without passing through the manager's `reset_pad`.
fn clear_rich(&self, _st: &mut Self::State) {}
}
/// All virtual pads of one stateful backend, driven from decoded controller events — the shared
@@ -107,6 +131,10 @@ pub struct UhidManager<B: PadProto> {
/// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
/// [`pump`](Self::pump).
last_active: Vec<Instant>,
/// When each pad last received a `RichInput::Motion`. `None` before the first sample and again
/// once the gyro has been neutralized, so a pad with no motion feed costs nothing per tick —
/// see [`MOTION_IDLE_TIMEOUT`].
last_motion: Vec<Option<Instant>>,
/// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`].
overflow_warn: Vec<OverflowWarn>,
}
@@ -182,6 +210,14 @@ impl OverflowWarn {
/// titles actually hit; the hatch below exists for exactly that experiment.
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
/// How long a pad's motion feed may go quiet before its angular velocity is zeroed — see
/// [`PadProto::neutralize_gyro`]. Wide enough to ride out a hiccup in a 250 Hz feed (~25 missed
/// samples, and the client's own capture floors are ~4 ms), tight enough that a feed which stops
/// for good doesn't hand the game a visible spin. Unlike [`RUMBLE_IDLE_TIMEOUT`] there is no
/// "legitimately held" case to protect: a still controller sends a zero sample, it does not stop
/// sending.
const MOTION_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
/// [`RUMBLE_IDLE_TIMEOUT`]; `0` disables the watchdog entirely (the pre-watchdog behavior, for
/// bisecting field reports). Non-zero overrides are floored just above SDL's ~2 s resend so the
@@ -222,6 +258,7 @@ impl<B: PadProto> UhidManager<B> {
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
last_active: vec![Instant::now(); MAX_PADS],
last_motion: vec![None; MAX_PADS],
overflow_warn: vec![OverflowWarn::default(); MAX_PADS],
}
}
@@ -240,8 +277,9 @@ impl<B: PadProto> UhidManager<B> {
}
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
self.reset_swept(swept);
let sweep = self.slots.sweep(f.active_mask);
self.reset_swept(sweep.dropped);
self.clear_reclaimed_rich(sweep.reclaimed);
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
@@ -267,6 +305,9 @@ impl<B: PadProto> UhidManager<B> {
if idx >= MAX_PADS || self.slots.get(idx).is_none() {
return;
}
if matches!(rich, RichInput::Motion { .. }) {
self.last_motion[idx] = Some(Instant::now());
}
self.backend.apply_rich(&mut self.state[idx], rich);
self.write(idx);
}
@@ -281,9 +322,17 @@ impl<B: PadProto> UhidManager<B> {
let Some(pad) = self.slots.get(i) else {
continue;
};
if self.backend.force_heartbeat(pad)
|| now.duration_since(self.last_write[i]) >= max_gap
{
let forced = self.backend.force_heartbeat(pad);
// A motion feed that stopped must not keep re-emitting its last angular velocity: the
// heartbeat below re-sends the current report forever, and with a real sensor clock
// each re-send carries an honestly larger dt — precisely the shape of phantom
// rotation. Zero the gyro once and stop watching until the feed comes back.
let mut neutralized = false;
if self.last_motion[i].is_some_and(|t| now.duration_since(t) >= MOTION_IDLE_TIMEOUT) {
self.last_motion[i] = None;
neutralized = self.backend.neutralize_gyro(&mut self.state[i]);
}
if neutralized || forced || now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
@@ -403,6 +452,19 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// Clear the rich-plane state of every slot a sweep re-claimed inside its grace window (see
/// [`Sweep::reclaimed`](crate::pad_slots::Sweep::reclaimed)). Rich fields only: rumble and the
/// hidout dedup deliberately survive a removal, and buttons/sticks arrive on the very frame
/// that re-set the mask bit.
fn clear_reclaimed_rich(&mut self, reclaimed: u16) {
for i in 0..MAX_PADS {
if reclaimed & (1 << i) != 0 {
self.backend.clear_rich(&mut self.state[i]);
self.last_motion[i] = None;
}
}
}
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
@@ -411,6 +473,17 @@ impl<B: PadProto> UhidManager<B> {
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.last_active[idx] = Instant::now();
self.last_motion[idx] = None;
}
/// Backdate every pad's motion clock past [`MOTION_IDLE_TIMEOUT`], so the next
/// [`heartbeat`](Self::heartbeat) neutralizes a stale gyro without a wall-clock sleep — the
/// same test hatch `PadSlots::expire_grace` gives the unplug debounce. Test-only.
#[cfg(test)]
fn expire_motion(&mut self) {
for t in self.last_motion.iter_mut().flatten() {
*t -= MOTION_IDLE_TIMEOUT;
}
}
}
@@ -434,6 +507,10 @@ mod tests {
/// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`,
/// must survive `merge_frame`.
rich_marker: u16,
/// Stands in for angular velocity — zeroed by the idle-motion watchdog.
gyro: i16,
/// Stands in for acceleration, which the watchdog must NOT zero (gravity is persistent).
accel: i16,
}
/// Per-pad transport stub recording every state write.
@@ -463,14 +540,33 @@ mod tests {
fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState {
MockState {
buttons: f.buttons,
rich_marker: prev.rich_marker, // the preserve-rich-fields contract
// The preserve-rich-fields contract — and the reason a stale motion sample lives
// forever without a watchdog.
rich_marker: prev.rich_marker,
gyro: prev.gyro,
accel: prev.accel,
}
}
fn apply_rich(&self, st: &mut MockState, rich: RichInput) {
if let RichInput::Touchpad { x, .. } = rich {
st.rich_marker = x;
match rich {
RichInput::Touchpad { x, .. } => st.rich_marker = x,
RichInput::Motion { gyro, accel, .. } => {
st.gyro = gyro[0];
st.accel = accel[2];
}
_ => {}
}
}
fn neutralize_gyro(&self, st: &mut MockState) -> bool {
let changed = st.gyro != 0;
st.gyro = 0;
changed
}
fn clear_rich(&self, st: &mut MockState) {
st.rich_marker = 0;
st.gyro = 0;
st.accel = 0;
}
fn write_state(&self, pad: &mut MockPad, st: &MockState) {
pad.writes.borrow_mut().push(*st);
}
@@ -506,10 +602,97 @@ mod tests {
}
}
fn motion(pad: u8, gyro_x: i16, accel_z: i16) -> RichInput {
RichInput::Motion {
pad,
gyro: [gyro_x, 0, 0],
accel: [0, 0, accel_z],
}
}
fn mgr() -> UhidManager<MockProto> {
UhidManager::new()
}
/// A motion feed that stops must not leave the pad rotating forever: past
/// [`MOTION_IDLE_TIMEOUT`] the heartbeat zeroes the angular velocity, keeps the acceleration,
/// and writes the corrected report. `max_gap` is huge here so the ONLY thing that can produce
/// a write is the neutralize.
#[test]
fn a_stalled_motion_feed_has_its_gyro_neutralized() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
m.apply_rich(motion(0, 900, 10_000));
assert_eq!(m.state[0].gyro, 900);
m.heartbeat(Duration::from_secs(3600));
assert_eq!(m.state[0].gyro, 900, "neutralized inside the idle window");
m.expire_motion();
m.heartbeat(Duration::from_secs(3600));
assert_eq!(
m.state[0].gyro, 0,
"stale angular velocity outlived the watchdog"
);
assert_eq!(
m.state[0].accel, 10_000,
"gravity must survive the neutralize"
);
let pad = m.slots.get(0).unwrap();
let writes = pad.writes.borrow();
assert_eq!(
writes.last().unwrap().gyro,
0,
"the neutralized state never reached the pad"
);
}
/// …and only when there is something to zero: a pad already at rest must not manufacture a
/// write on every tick.
#[test]
fn neutralizing_an_already_still_pad_writes_nothing() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
m.apply_rich(motion(0, 0, 10_000));
let before = m.slots.get(0).unwrap().writes.borrow().len();
m.expire_motion();
m.heartbeat(Duration::from_secs(3600));
m.heartbeat(Duration::from_secs(3600));
assert_eq!(m.slots.get(0).unwrap().writes.borrow().len(), before);
}
/// A controller that takes over a live pad inside the replug grace skips the create path, so
/// nothing else resets what the manager persists on the client's behalf. It must not inherit
/// the previous controller's finger or rotation — a pad with no gyro would carry that
/// rotation for the rest of the session, having no sample of its own to correct it with.
#[test]
fn a_grace_reclaim_clears_the_previous_pads_rich_state() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
m.apply_rich(touch(0, 4242));
m.apply_rich(motion(0, 900, 10_000));
m.handle(&frame(0, 0b0, 0)); // the unplug frame: arms the grace, drops nothing
assert!(m.slots.get(0).is_some(), "the grace must not drop it here");
m.handle(&frame(0, 0b1, 0)); // back inside the grace — same pad, new owner
assert_eq!(m.state[0].rich_marker, 0, "inherited the last pad's touch");
assert_eq!(m.state[0].gyro, 0, "inherited the last pad's rotation");
}
/// The re-claim clear keys off the grace clock, not off presence — a steady mask must never
/// trip it, or the client's touch and motion would be wiped on every state frame.
#[test]
fn a_steady_mask_never_clears_rich_state() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
m.apply_rich(touch(0, 4242));
for _ in 0..5 {
m.handle(&frame(0, 0b1, 0));
}
assert_eq!(m.state[0].rich_marker, 4242);
}
#[test]
fn arrival_eager_creates_the_pad() {
// G10 as a generic regression test: Arrival must build the device before the first frame.
@@ -68,6 +68,14 @@ impl PadProto for DsEdgeWinProto {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
@@ -23,12 +23,13 @@ use super::dualsense_proto::{
DS_TOUCH_W,
};
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::sensor_clock::SensorClock;
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{anyhow, Result};
use punktfunk_core::quic::RichInput;
use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use windows::core::{w, GUID, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
@@ -70,6 +71,46 @@ pub(super) const OFF_OUT_RING: usize =
pub(super) const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
pub(super) const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
pub(super) const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22;
/// v2.3 input seqlock — see [`publish_input`] and the `PadShm` docs.
pub(super) const OFF_INPUT_GEN: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input_gen);
/// Publish one HID input report into the section's input slot under the v2.3 seqlock, so a driver
/// reading concurrently can tell a whole report from a half-written one.
///
/// The slot is a single unqueued buffer neither side locks: the driver's timer copies 64 bytes out
/// of it whenever it likes, including in the middle of this write. The result is a frame that is
/// part previous report and part next — a one-tick glitch for a button, but for motion a spike in
/// angular velocity, and a game integrating gyro aim turns that spike into aim it never asked for.
///
/// `generation` is the pad's own counter, taken to **odd** before the body goes down and back to
/// **even** after; a driver samples it either side of its read and retries when the two disagree.
/// The `Release` fence keeps the body stores from sinking above the odd marker, and the `Release`
/// store publishes them ahead of the even one — both no-ops on x86-TSO and load-bearing on ARM64.
///
/// # Safety
/// `base` must point at a live mapped pad section of at least `PAD_SHM_SIZE` bytes, and `report`
/// must be no longer than the 64-byte input slot.
pub(super) unsafe fn publish_input(base: *mut u8, generation: &mut u32, report: &[u8]) {
debug_assert!(report.len() <= 64, "report overruns the input slot");
// Odd: a report is in flight.
*generation = generation.wrapping_add(1);
// SAFETY: the caller guarantees `base` maps the section; `OFF_INPUT_GEN` (== 168) is 4-aligned
// off the page-aligned base and sits in the v2 legacy region every driver generation maps.
unsafe {
(*(base.add(OFF_INPUT_GEN) as *const AtomicU32)).store(*generation, Ordering::Relaxed)
};
// Ordered, not ordering: keeps the body stores below from being hoisted above the odd marker.
fence(Ordering::Release);
// SAFETY: the caller guarantees the mapping and that `report` fits the slot at OFF_INPUT.
unsafe { std::ptr::copy_nonoverlapping(report.as_ptr(), base.add(OFF_INPUT), report.len()) };
// Even: the slot holds a whole report again.
*generation = generation.wrapping_add(1);
// SAFETY: as the first store.
unsafe {
(*(base.add(OFF_INPUT_GEN) as *const AtomicU32)).store(*generation, Ordering::Release)
};
}
/// Shared drain over a pad section's output plane — the lossless report ring when the driver
/// publishes one (8 slots from a v2.1 driver, [`OUT_RING_LEN_V22`] once both sides negotiated the
@@ -216,7 +257,9 @@ pub struct DsWinPad {
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
seq: u8,
ts: u32,
clock: SensorClock,
/// This pad's v2.3 input-seqlock generation — see [`publish_input`].
input_gen: u32,
/// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
@@ -511,7 +554,8 @@ impl DsWinPad {
instance_id,
),
seq: 0,
ts: 0,
clock: SensorClock::dualsense(),
input_gen: 0,
drain: OutputDrain::new(),
})
}
@@ -519,28 +563,17 @@ impl DsWinPad {
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
pub(super) fn write_state(&mut self, st: &DsState) {
self.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1);
let ts = self.clock.ds_ticks(Instant::now());
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
// field to publish last: the `pf_gamepad` driver streams the whole `input` region to game
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
// driver change, deferred). The `Release` fence after the copy orders the report-body stores
// ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`),
// giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a
// no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn
// single frame is still theoretically possible but self-heals on the next ~250 Hz write.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
);
fence(Ordering::Release);
};
serialize_state(&mut r, st, self.seq, ts);
// The input path has no driver-polled change-detect field the way the XUSB `packet` /
// DualSense `out_seq` planes do — the driver streams the whole `input` region to game
// READ_REPORTs on its timer, and the report's own counter (r[7], mid-report) belongs to
// the game's HID stack, not the driver. That used to leave a torn single frame possible.
// The v2.3 seqlock closes it: see `publish_input`.
// SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte
// input report.
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
}
/// Drain the section's output plane; parse every new `0x02` report (rumble / LEDs / triggers)
@@ -631,6 +664,14 @@ impl PadProto for DsWinProto {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
@@ -9,17 +9,18 @@
use super::dualsense_proto::DsState;
use super::dualsense_windows::{
create_swdevice, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE,
OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
create_swdevice, publish_input, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE,
OFF_DRIVER_PROTO, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
};
use super::gamepad_raii::PadChannel;
use crate::sensor_clock::SensorClock;
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::Duration;
use std::time::{Duration, Instant};
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that).
@@ -37,7 +38,9 @@ pub struct Ds4WinPad {
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
counter: u8,
ts: u16,
clock: SensorClock,
/// This pad's v2.3 input-seqlock generation — see `publish_input`.
input_gen: u32,
/// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
@@ -105,7 +108,8 @@ impl Ds4WinPad {
instance_id,
),
counter: 0,
ts: 0,
clock: SensorClock::dualshock4(),
input_gen: 0,
drain: OutputDrain::new(),
})
}
@@ -113,17 +117,12 @@ impl Ds4WinPad {
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
fn write_state(&mut self, st: &DsState) {
self.counter = self.counter.wrapping_add(1);
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
let ts = self.clock.ds4_ticks(Instant::now());
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.counter, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
};
serialize_state(&mut r, st, self.counter, ts);
// SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte
// input report. Publishes under the v2.3 seqlock — see `publish_input`.
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
}
/// Drain the section's output plane; parse every new `0x05` report (rumble / lightbar) into a
@@ -216,6 +215,14 @@ impl PadProto for Ds4WinProto {
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
}
fn neutralize_gyro(&self, st: &mut DsState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut DsState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
pad.write_state(st);
}
@@ -320,7 +320,8 @@ impl GamepadManager {
}
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
// XUSB pads carry no rich plane, so a grace re-claim has nothing to clear.
let swept = self.slots.sweep(f.active_mask).dropped;
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return;
@@ -18,8 +18,8 @@
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
use super::dualsense_windows::{
create_swdevice, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
create_swdevice, publish_input, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO,
OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::gamepad_raii::PadChannel;
use super::steam_proto::{
@@ -45,6 +45,8 @@ pub struct DeckWinPad {
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
seq: u32,
/// This pad's v2.3 input-seqlock generation — see `publish_input`.
input_gen: u32,
/// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
@@ -106,6 +108,7 @@ impl DeckWinPad {
instance_id,
),
seq: 0,
input_gen: 0,
drain: OutputDrain::new(),
})
}
@@ -115,14 +118,12 @@ impl DeckWinPad {
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, st, self.seq);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
};
// This path had neither the trailing `Release` its DualSense sibling carried nor any
// publish marker, so a driver read could land mid-copy AND the body stores had no ordering
// against the pad's next publish. `publish_input` gives it both (v2.3 seqlock).
// SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte
// Deck state frame.
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
}
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
@@ -210,6 +211,14 @@ impl PadProto for DeckWinProto {
st.apply_rich(rich);
}
fn neutralize_gyro(&self, st: &mut SteamState) -> bool {
st.neutralize_gyro()
}
fn clear_rich(&self, st: &mut SteamState) {
st.clear_rich();
}
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
pad.write_state(st);
}
+6
View File
@@ -398,6 +398,12 @@ pub mod pad_gate;
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_slots.rs"]
pub mod pad_slots;
/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports
/// ([`sensor_clock::SensorClock`]) — real elapsed time in the DualSense's 1/3 µs and the
/// DualShock 4's 5.33 µs units, shared by all four backends.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/sensor_clock.rs"]
pub mod sensor_clock;
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"]
+405
View File
@@ -0,0 +1,405 @@
//! The motion **unit contract**, pinned across every side that has an opinion about it.
//!
//! Gyro aim integrates angular velocity over time, so a scale error is not a cosmetic wrongness —
//! it is every rotation being the wrong size, forever. The wire carries raw `i16` LSBs in the
//! DualSense convention ([`MOTION_GYRO_LSB_PER_DEG_S`] / [`MOTION_ACCEL_LSB_PER_G`]), and each host
//! backend re-states that convention in its own dialect: the Sony pads *declare* it in a fixed
//! calibration feature report the consumer reads its scale out of, the Steam Deck and Switch Pro
//! backends *rescale* into their driver's native resolution.
//!
//! Nothing used to check that those re-statements agreed with the wire. They didn't: the DualShock
//! 4 blob declared 0.5 LSB/°·s and 8192 LSB/g against a wire delivering 20 and 10000, so every DS4
//! session read gyro 40× too fast and accel 1.22× hot — in two byte-identical copies, one of them
//! in a driver that lives in a different cargo workspace. This file is the gate that would have
//! caught it: it applies the *consumer's* arithmetic to each backend's declaration and asserts the
//! result lands back on the wire constants.
//!
//! Adding a motion-capable backend means adding it here.
#![cfg(any(target_os = "linux", target_os = "windows"))]
use pf_inject::dualsense_proto::{
serialize_state as ds_serialize, DsState, DS_FEATURE_CALIBRATION, DS_INPUT_REPORT_LEN,
DS_TOUCH_H, DS_TOUCH_W,
};
use pf_inject::dualshock4_proto::{
serialize_state as ds4_serialize, DS4_FEATURE_CALIBRATION, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H,
DS4_TOUCH_W,
};
use pf_inject::steam_proto::SteamState;
use pf_inject::steam_remap::motion_wire_to_deck;
use pf_inject::switch_proto::SwitchState;
use punktfunk_core::input::gamepad::{
MOTION_ACCEL_LSB_PER_G, MOTION_GYRO_LSB_PER_DEG_S, MOTION_NEUTRAL_ACCEL,
};
use punktfunk_core::quic::RichInput;
/// The Sony IMU-calibration feature report, whose layout is the same for the DualSense (report
/// `0x05`) and the USB DualShock 4 (report `0x02`): report id, three signed bias words, six
/// **interleaved** per-axis `plus`/`minus` words, two gyro `speed` words, then six accel
/// `plus`/`minus` words, all little-endian `i16`.
///
/// ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses before the
/// three minuses, and consumers switch layout on the transport; our virtual pads declare `BUS_USB`,
/// so interleaved is correct here — this was checked and is not a latent bug.
#[derive(Debug, PartialEq, Eq)]
struct SonyImuCalibration {
gyro_bias: [i16; 3],
gyro_plus: [i16; 3],
gyro_minus: [i16; 3],
/// `speed_plus`, `speed_minus` — the reference rotation rate the plus/minus span was measured
/// at. Consumers only ever use the sum.
gyro_speed: [i16; 2],
accel_plus: [i16; 3],
accel_minus: [i16; 3],
}
impl SonyImuCalibration {
fn parse(blob: &[u8], report_id: u8, who: &str) -> SonyImuCalibration {
assert_eq!(blob.first().copied(), Some(report_id), "{who}: report id");
assert!(
blob.len() >= 35,
"{who}: {} bytes, too short to carry the calibration fields (need 35)",
blob.len()
);
let w = |i: usize| i16::from_le_bytes([blob[i], blob[i + 1]]);
SonyImuCalibration {
gyro_bias: [w(1), w(3), w(5)],
gyro_plus: [w(7), w(11), w(15)],
gyro_minus: [w(9), w(13), w(17)],
gyro_speed: [w(19), w(21)],
accel_plus: [w(23), w(27), w(31)],
accel_minus: [w(25), w(29), w(33)],
}
}
/// LSB per °/s that the **kernel** derives for axis `i`: `hid-playstation` sets
/// `sens_numer = (speed_plus + speed_minus) * GYRO_RES_PER_DEG_S` and
/// `sens_denom = |plus - bias| + |minus - bias|`, then reports
/// `raw * sens_numer / sens_denom` in units of 1/`GYRO_RES_PER_DEG_S` °/s — so the
/// `GYRO_RES_PER_DEG_S` cancels and the resolution the pad *advertises* is `denom / speed_2x`,
/// independent of the driver's internal fixed-point scale.
///
/// Returned as an integer because a fractional answer is itself a defect: no consumer can
/// round-trip a resolution it cannot express, and the assert below is where the pre-2026-08
/// DS4 blob (32/64 = 0.5) fails.
fn kernel_gyro_lsb_per_deg_s(&self, i: usize, who: &str) -> i64 {
let speed_2x = self.gyro_speed[0] as i64 + self.gyro_speed[1] as i64;
assert!(
speed_2x != 0,
"{who}: gyro speed_plus + speed_minus is zero"
);
let denom = (self.gyro_plus[i] as i64 - self.gyro_bias[i] as i64).abs()
+ (self.gyro_minus[i] as i64 - self.gyro_bias[i] as i64).abs();
assert_eq!(
denom % speed_2x,
0,
"{who} axis {i}: declares a fractional {denom}/{speed_2x} LSB per °/s"
);
denom / speed_2x
}
/// The same number as SDL derives it (`SDL_hidapi_ps4` / `SDL_hidapi_ps5`: `plus - minus` over
/// the speed sum, ignoring the bias). It agrees with the kernel's form only for a symmetric,
/// zero-bias blob — and both consumers read the same virtual pad, so a blob they disagree
/// about is a bug no matter which one is "right".
fn sdl_gyro_lsb_per_deg_s(&self, i: usize) -> f64 {
(self.gyro_plus[i] as f64 - self.gyro_minus[i] as f64)
/ (self.gyro_speed[0] as f64 + self.gyro_speed[1] as f64)
}
/// LSB per g for axis `i`: consumers take `range_2g = plus - minus` as the span of **2 g**, so
/// one g is half of it.
fn accel_lsb_per_g(&self, i: usize, who: &str) -> i64 {
let range_2g = self.accel_plus[i] as i64 - self.accel_minus[i] as i64;
assert_eq!(
range_2g % 2,
0,
"{who} axis {i}: odd accel range {range_2g} has no exact 1 g"
);
range_2g / 2
}
/// The raw value a consumer treats as zero g (`plus - range_2g / 2`). Our pads pass the wire
/// through unscaled, and the wire's zero is 0, so this must be 0 — a non-zero bias would show
/// up as a constant phantom acceleration.
fn accel_zero_point(&self, i: usize) -> i64 {
let range_2g = self.accel_plus[i] as i64 - self.accel_minus[i] as i64;
self.accel_plus[i] as i64 - range_2g / 2
}
}
/// Every Sony-dialect backend declares exactly the wire's units, to both of its consumers.
#[test]
fn sony_calibration_blobs_declare_the_wire_units() {
let wire_gyro = MOTION_GYRO_LSB_PER_DEG_S as i64;
let wire_accel = MOTION_ACCEL_LSB_PER_G as i64;
for (who, blob, report_id) in [
("DualSense 0x05", DS_FEATURE_CALIBRATION, 0x05u8),
("DualShock 4 0x02", DS4_FEATURE_CALIBRATION, 0x02u8),
] {
let cal = SonyImuCalibration::parse(blob, report_id, who);
for axis in 0..3 {
assert_eq!(
cal.kernel_gyro_lsb_per_deg_s(axis, who),
wire_gyro,
"{who} axis {axis}: gyro resolution the kernel derives"
);
assert_eq!(
cal.sdl_gyro_lsb_per_deg_s(axis),
wire_gyro as f64,
"{who} axis {axis}: gyro resolution SDL derives"
);
assert_eq!(
cal.accel_lsb_per_g(axis, who),
wire_accel,
"{who} axis {axis}: accel resolution"
);
assert_eq!(
cal.accel_zero_point(axis),
0,
"{who} axis {axis}: accel zero point must be the wire's 0"
);
}
}
}
/// The two rescaling backends land a wire sample on their driver's native resolution.
#[test]
fn rescaling_backends_convert_the_wire_into_their_native_units() {
// One reference sample: 100 °/s and exactly 1 g, expressed on the wire.
let wire_gyro = (100 * MOTION_GYRO_LSB_PER_DEG_S) as i16;
let wire_accel = MOTION_ACCEL_LSB_PER_G as i16;
// Steam Deck: `hid-steam` fixes STEAM_DECK_GYRO_RES_PER_DPS = 16 and ACCEL_RES_PER_G = 16384.
let (gyro, accel) = motion_wire_to_deck([wire_gyro; 3], [wire_accel; 3]);
assert_eq!(gyro, [100 * 16; 3], "Deck gyro: 100 °/s at 16 LSB/°·s");
assert_eq!(accel, [16384; 3], "Deck accel: 1 g at 16384 LSB/g");
// Switch Pro: `hid-nintendo` fixes JC_IMU_GYRO_RES_PER_DPS = 14.247 and ACCEL_RES_PER_G = 4096,
// and consumes our report 1:1 because the factory-calibration blob we serve is the driver's own
// identity default. 100 °/s × 14.247 = 1424.7, truncated.
let mut st = SwitchState::neutral();
st.apply_motion([wire_gyro; 3], [wire_accel; 3]);
assert_eq!(st.gyro, [1424; 3], "Switch gyro: 100 °/s at 14.247 LSB/°·s");
assert_eq!(st.accel, [4096; 3], "Switch accel: 1 g at 4096 LSB/g");
}
/// The DualSense / DualShock 4 backends hand the wire sample to the report codec **unscaled** —
/// which is only correct because their calibration blobs declare the wire's own units above. If
/// someone ever adds a rescale here, the blobs have to move with it (or vice versa).
#[test]
fn sony_backends_pass_the_wire_sample_through_unscaled() {
let gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16, -640, 7];
let accel = [0, 0, MOTION_ACCEL_LSB_PER_G as i16];
let motion = RichInput::Motion {
pad: 0,
gyro,
accel,
};
// Both Sony backends share `DsState::apply_rich`, differing only in touchpad extent.
for (who, w, h) in [
("DualSense", DS_TOUCH_W, DS_TOUCH_H),
("DualShock 4", DS4_TOUCH_W, DS4_TOUCH_H),
] {
let mut st = DsState::neutral();
st.apply_rich(motion, w, h);
assert_eq!(st.gyro, gyro, "{who} rescaled the wire gyro");
assert_eq!(st.accel, accel, "{who} rescaled the wire accel");
}
}
/// The client→report path end to end, in the units that matter: a wire Motion sample must reach
/// the HID report's motion fields as those exact little-endian values. The proto tests already pin
/// the OFFSETS; nothing pinned that the VALUE arrives unscaled — which is the half the calibration
/// blobs above are a promise about.
#[test]
fn a_wire_motion_sample_reaches_the_report_bytes_unchanged() {
let g = (100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; // 100 °/s = 2000 = 0x07D0
let a = MOTION_ACCEL_LSB_PER_G as i16; // 1 g = 10000 = 0x2710
let motion = RichInput::Motion {
pad: 0,
gyro: [g, -g, 0],
accel: [0, 0, a],
};
let gyro_le = [0xD0, 0x07, 0x30, 0xF8, 0x00, 0x00]; // 2000, 2000, 0
let accel_le = [0x00, 0x00, 0x00, 0x00, 0x10, 0x27]; // 0, 0, 10000
// DualSense report 0x01: gyro at bytes 16..22, accel at 22..28.
let mut st = DsState::neutral();
st.apply_rich(motion, DS_TOUCH_W, DS_TOUCH_H);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
ds_serialize(&mut r, &st, 0, 0);
assert_eq!(&r[16..22], &gyro_le, "DualSense report gyro");
assert_eq!(&r[22..28], &accel_le, "DualSense report accel");
// DualShock 4 report 0x01: gyro at 13..19, accel at 19..25.
let mut st = DsState::neutral();
st.apply_rich(motion, DS4_TOUCH_W, DS4_TOUCH_H);
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
ds4_serialize(&mut r, &st, 0, 0);
assert_eq!(&r[13..19], &gyro_le, "DualShock 4 report gyro");
assert_eq!(&r[19..25], &accel_le, "DualShock 4 report accel");
}
/// The idle-motion watchdog's semantics, which only make sense in these units: angular velocity
/// goes to zero when the feed stops, acceleration does not — a still controller still measures
/// gravity, and blanking it would read as free-fall.
#[test]
fn neutralizing_motion_keeps_gravity() {
let mut st = DsState::neutral();
st.gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; 3];
st.accel = [0, 0, MOTION_ACCEL_LSB_PER_G as i16];
assert!(st.neutralize_gyro(), "reported no change while rotating");
assert_eq!(st.gyro, [0; 3]);
assert_eq!(st.accel, [0, 0, MOTION_ACCEL_LSB_PER_G as i16]);
assert!(!st.neutralize_gyro(), "a still pad must report no change");
let mut deck = SteamState::neutral();
deck.gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; 3];
deck.accel = [0, 0, 16384];
assert!(deck.neutralize_gyro());
assert_eq!(deck.gyro, [0; 3]);
assert_eq!(deck.accel, [0, 0, 16384], "Deck gravity must survive too");
}
/// A virtual pad that has received no motion must read as STILL, not as falling.
///
/// `[0, 0, 0]` is not "no information": zero proper acceleration is free fall, a claim about the
/// physical world that is never true of a controller on a desk. Anything deriving orientation from
/// the accelerometer gets a confident wrong answer rather than a boring right one — and the pads
/// this affects most are the ones with no gyro at all, which sit on that neutral for the whole
/// session.
///
/// Each backend is checked in ITS OWN units, because the value differs per backend and hard-coding
/// "1 g" three times is how the two halves of a unit contract drift apart.
#[test]
fn every_backend_neutral_reads_as_a_still_pad_not_a_falling_one() {
// The wire's own answer, measured from a real DualSense on 2026-08-07: axis 1 is UP.
assert_eq!(MOTION_NEUTRAL_ACCEL, [0, MOTION_ACCEL_LSB_PER_G as i16, 0]);
let ds = DsState::neutral();
assert_eq!(
ds.accel, MOTION_NEUTRAL_ACCEL,
"a fresh DualSense/DS4 must report 1 g up, not free fall"
);
assert_eq!(ds.gyro, [0; 3], "and it must not be turning");
// The Deck rescales, so its neutral is the wire's put through the same conversion a real
// sample takes — asserted against the resolution `hid-steam` actually fixes (16384 LSB/g),
// so a change to either side has to face this line.
let deck = SteamState::neutral();
assert_eq!(
deck.accel,
motion_wire_to_deck([0; 3], MOTION_NEUTRAL_ACCEL).1,
"the Deck neutral must be the wire neutral, rescaled — not a second opinion about 1 g"
);
assert_eq!(deck.accel, [0, 16384, 0]);
assert_eq!(deck.gyro, [0; 3]);
// The Switch Pro already did this correctly and is deliberately NOT touched: it is a different
// device (hid-nintendo), its up axis is its own, and nobody has measured its frame. Pinned so
// that a well-meaning sweep does not "make it consistent" with the DualSense on no evidence.
let sw = SwitchState::neutral();
assert_eq!(
sw.accel,
[0, 0, 4096],
"switch_proto's neutral is its own device's; do not align it to the DualSense unmeasured"
);
// The property that actually matters, stated once per backend: none of them is in free fall.
for (what, accel) in [
("dualsense", ds.accel),
("deck", deck.accel),
("switch", sw.accel),
] {
assert_ne!(accel, [0; 3], "{what} neutral reads as free fall");
let mag = accel
.iter()
.map(|&v| (v as f64).powi(2))
.sum::<f64>()
.sqrt();
assert!(mag > 0.0, "{what} neutral has no gravity at all");
}
}
// ---- the Windows UMDF driver's copies ----
/// `packaging/windows/drivers/pf-gamepad` is a separate WDK cargo workspace: it cannot depend on
/// pf-inject, so it carries its own copies of the calibration blobs. That is exactly the shape the
/// DS4 bug shipped in — one wrong table living in two files, where fixing one reads as fixing it.
/// Rather than trust a "keep in sync" comment, derive the units from the driver's own source.
const DRIVER_SRC: &str = include_str!("../../../packaging/windows/drivers/pf-gamepad/src/lib.rs");
/// Pull the bytes out of a `static NAME: [u8; N] = [ … ];` (or `const NAME: &[u8] = &[ … ];`)
/// literal in Rust source. Deliberately dumb: the arrays it reads are `#[rustfmt::skip]` tables of
/// `0x..` bytes, and a scan that breaks fails this test loudly rather than passing vacuously.
fn extract_byte_array(src: &str, name: &str) -> Vec<u8> {
let decl = src
.find(&format!("{name}:"))
.unwrap_or_else(|| panic!("{name} not found in the driver source"));
let eq = src[decl..]
.find('=')
.unwrap_or_else(|| panic!("{name}: no `=` after the declaration"))
+ decl;
let open = src[eq..]
.find('[')
.unwrap_or_else(|| panic!("{name}: no `[` after the `=`"))
+ eq;
let close = src[open..]
.find("];")
.unwrap_or_else(|| panic!("{name}: array literal is not closed by `];`"))
+ open;
let bytes: Vec<u8> = src[open + 1..close]
.lines()
.map(|l| l.split("//").next().unwrap_or("")) // drop trailing comments
.flat_map(|l| l.split(','))
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| {
let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X"));
match hex {
Some(h) => u8::from_str_radix(h, 16),
None => t.parse(),
}
.unwrap_or_else(|_| panic!("{name}: {t:?} is not a byte literal"))
})
.collect();
assert!(!bytes.is_empty(), "{name}: extracted no bytes");
bytes
}
/// The driver's blobs declare the same calibration as pf-inject's, field for field, and therefore
/// the same units. Trailing padding may differ (the two transports declare different feature
/// lengths), so this compares the parsed fields rather than raw bytes.
#[test]
fn windows_driver_blobs_match_the_canonical_ones() {
let wire_gyro = MOTION_GYRO_LSB_PER_DEG_S as i64;
let wire_accel = MOTION_ACCEL_LSB_PER_G as i64;
for (who, canonical, report_id) in [
("DualSense 0x05", DS_FEATURE_CALIBRATION, 0x05u8),
("DualShock 4 0x02", DS4_FEATURE_CALIBRATION, 0x02u8),
] {
let name = if report_id == 0x05 {
"DS_FEATURE_CALIBRATION"
} else {
"DS4_FEATURE_CALIBRATION"
};
let driver_blob = extract_byte_array(DRIVER_SRC, name);
let driver = SonyImuCalibration::parse(&driver_blob, report_id, &format!("driver {who}"));
assert_eq!(
driver,
SonyImuCalibration::parse(canonical, report_id, who),
"the UMDF driver's {name} has drifted from pf-inject's"
);
for axis in 0..3 {
let d = format!("driver {who}");
assert_eq!(driver.kernel_gyro_lsb_per_deg_s(axis, &d), wire_gyro);
assert_eq!(driver.accel_lsb_per_g(axis, &d), wire_accel);
}
}
}
+11
View File
@@ -345,6 +345,14 @@ pub struct NativeClient {
/// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]).
/// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback).
pub resolved_gamepad: GamepadPref,
/// The session default this client's Hello ASKED for, kept beside the host's answer above.
///
/// The pair is what makes the echo usable per pad: the host applies the same fold to a pad's
/// own declaration as it did to this, so `resolved` is that pad's answer exactly when the pad
/// declared `requested_gamepad` — and only a guess otherwise. See
/// [`pad_motion_reaches`](crate::config::pad_motion_reaches), which is the one place that
/// reasoning lives.
pub requested_gamepad: GamepadPref,
/// The encoder bitrate the host actually configured ([`Welcome::bitrate_kbps`], kbps): our
/// requested rate clamped to the host's range, or its default if we requested `0`. `0` = an
/// older host that didn't report it.
@@ -736,6 +744,9 @@ impl NativeClient {
host_fingerprint: negotiated.host_fingerprint,
resolved_compositor: negotiated.compositor,
resolved_gamepad: negotiated.gamepad,
// What we asked for, not what came back — the two together are what let a client ask
// the motion question per pad (see the field's doc).
requested_gamepad: gamepad,
resolved_bitrate_kbps: negotiated.bitrate_kbps,
shard_payload: negotiated.shard_payload,
clock_offset_ns: negotiated.clock_offset_ns,
+142
View File
@@ -189,6 +189,40 @@ pub enum GamepadPref {
}
impl GamepadPref {
/// Whether this backend has a motion plane at all — i.e. whether a `RichInput::Motion` sample
/// sent to a host running it can reach the game, or is decoded and dropped.
///
/// The X-Box classes have no gyro in their HID contract, so a client whose local pad HAS one
/// is streaming ~250 Hz of datagrams into a void: the host parses each and discards it, and
/// the player sees a controller whose gyro silently does nothing.
///
/// This answers for ONE backend. To ask it of a particular pad, go through
/// [`pad_motion_reaches`] — the session's [`Welcome::gamepad`](crate::quic::Welcome::gamepad)
/// echo is not that pad's answer, because the host builds each virtual device from the pad's
/// own `GamepadArrival` and falls back to the session default only for a pad that never
/// declared one.
///
/// `Auto` answers `true` on purpose. It means "unknown": either a host too old to echo the
/// field, or one that hasn't resolved yet. Suppressing motion on unknown would silently break
/// gyro against every old host that did resolve to a DualSense, which is a worse failure than
/// sending datagrams nobody reads.
///
/// Exhaustive by design — a new backend has to state its answer here rather than inherit one.
pub const fn has_motion(self) -> bool {
match self {
GamepadPref::Auto => true, // unknown; assume it can, see above
GamepadPref::Xbox360 | GamepadPref::XboxOne => false,
GamepadPref::DualSense
| GamepadPref::DualShock4
| GamepadPref::DualSenseEdge
| GamepadPref::SwitchPro
| GamepadPref::SteamController
| GamepadPref::SteamDeck
| GamepadPref::SteamController2
| GamepadPref::SteamController2Puck => true,
}
}
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`, `10 = SteamController2Puck`.
@@ -273,6 +307,45 @@ impl GamepadPref {
}
}
/// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad announced
/// in its [`InputKind::GamepadArrival`](crate::input::InputKind::GamepadArrival), `asked` is the
/// session default the Hello carried, and `resolved` is the host's
/// [`Welcome::gamepad`](crate::quic::Welcome::gamepad) echo.
///
/// Three facts make this a per-pad question rather than a session one:
///
/// 1. The host builds each virtual device from that pad's arrival — `Pads::set_kind` — and uses
/// the session default only for a pad that never declares. So the echo is simply not this
/// pad's answer when the two differ.
/// 2. The host FOLDS what it cannot build (`resolve_gamepad`/`resolve_pad_kind` share one
/// `pick_gamepad`): a Switch Pro on a Windows host, or any UHID backend on a host whose
/// `/dev/uhid` is unusable, lands on X-Box 360 with the motion plane gone. Nothing local can
/// predict that.
/// 3. But the echo IS one observed sample of that fold — for the kind the Hello asked about. When
/// a pad declared exactly that kind, the host ran the same fold on the same input, so the echo
/// is authoritative for it.
///
/// Hence: trust the echo for a pad that declared what we asked for, and otherwise fall back to
/// what the declaration alone can tell us. That keeps both motivating cases: a generic pad under
/// `Auto` (declares X-Box 360, no motion plane, suppressed) and an explicit Switch Pro folded to
/// X-Box 360 by a Windows host (declared == asked, so the echo catches it).
///
/// The residual gap is a pad whose declared kind differs from the session's AND gets folded — we
/// keep sending, and the host keeps dropping. That is the direction to be wrong in: the failure
/// is wasted datagrams, where guessing the other way would silently kill a working gyro.
pub const fn pad_motion_reaches(
declared: GamepadPref,
asked: GamepadPref,
resolved: GamepadPref,
) -> bool {
// `==` on a fieldless enum, spelled as a match because PartialEq::eq is not const.
if declared.to_u8() == asked.to_u8() {
resolved.has_motion()
} else {
declared.has_motion()
}
}
/// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as
/// GameStream does: `m = ceil(k * fec_percent / 100)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -754,6 +827,75 @@ mod tests {
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
}
/// Which backends a client may stream motion to. Pinned as a table because the answer decides
/// whether a player's gyro works at all, and getting it wrong in either direction is silent:
/// a false negative kills working motion, a false positive keeps ~250 Hz of datagrams flowing
/// into a host that drops every one.
#[test]
fn only_the_xbox_classes_lack_a_motion_plane() {
for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] {
assert!(
!p.has_motion(),
"{} should have no motion plane",
p.as_str()
);
}
for p in [
GamepadPref::DualSense,
GamepadPref::DualShock4,
GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro,
GamepadPref::SteamController,
GamepadPref::SteamDeck,
GamepadPref::SteamController2,
GamepadPref::SteamController2Puck,
] {
assert!(p.has_motion(), "{} should carry motion", p.as_str());
}
// Unknown must not suppress: an old host that omitted the echo may well have resolved a
// DualSense, and silently killing its gyro is worse than sending into a void.
assert!(GamepadPref::Auto.has_motion());
}
/// The per-pad question, case by case. Each row is a session a player can actually sit down
/// to; the comment says which of the three inputs decides it.
#[test]
fn motion_reach_is_answered_per_pad_not_per_session() {
use GamepadPref::*;
// The case this predicate exists for, and the one a session-level check gets WRONG:
// "Automatic" with mixed pads. The Hello carries the active pad's kind (an X-Box pad), so
// the echo says X-Box 360 — but pad 1 declared a DualSense and the host built it one, with
// a motion plane. Reading the echo here kills a gyro that works.
assert!(pad_motion_reaches(DualSense, Xbox360, Xbox360));
// Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion.
assert!(!pad_motion_reaches(Xbox360, Xbox360, Xbox360));
// A generic pad (8BitDo &c.) under Automatic — the sweep's motivating case. Detection
// lands on X-Box 360, the pad declares it, and its gyro has no plane to reach.
assert!(!pad_motion_reaches(Xbox360, Xbox360, Xbox360));
// An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared ==
// asked, so the echo is this pad's answer and catches a fold nothing local could predict.
assert!(!pad_motion_reaches(SwitchPro, SwitchPro, Xbox360));
// The same declaration against a Linux host that builds it: unchanged, motion reaches.
assert!(pad_motion_reaches(SwitchPro, SwitchPro, SwitchPro));
// A DualSense wish on a host with no usable /dev/uhid degrades the same way.
assert!(!pad_motion_reaches(DualSense, DualSense, Xbox360));
// Nobody connected at dial time, so the Hello asked `Auto` and the host resolved it from
// its own env. A pad that shows up later declares its own kind and is judged on that —
// whichever way the session went.
assert!(pad_motion_reaches(DualSense, Auto, Xbox360));
assert!(!pad_motion_reaches(Xbox360, Auto, DualSense));
// An old host that echoes nothing leaves `Auto`, which must not suppress: it may well have
// resolved a DualSense, and silently killing gyro is the worse of the two failures.
assert!(pad_motion_reaches(DualSense, DualSense, Auto));
// Even then the declaration still speaks when it is the thing without a plane.
assert!(!pad_motion_reaches(Xbox360, DualSense, Auto));
}
#[test]
fn gamepad_pref_wire_and_names() {
for p in [
+37
View File
@@ -180,6 +180,43 @@ pub mod gamepad {
/// Triggers: value range 0..255.
pub const AXIS_LT: u32 = 4;
pub const AXIS_RT: u32 = 5;
/// Motion wire units — the DualSense convention, raw `i16` LSBs, carried by
/// `RichInput::Motion`. Gyro is angular velocity, accel is proper acceleration.
///
/// Every capture path scales *into* these units (`pf-client-core::gamepad`, Swift
/// `GamepadWire`, the Android `DeviceGyro`) and every host backend decodes *from* them —
/// but the two sides never meet in one crate, which is how a virtual pad shipped for
/// months telling its consumers to read the same bytes 40× too fast. The host's virtual
/// pads carry fixed calibration blobs, and the resolution a consumer derives from those
/// blobs must land back on exactly these numbers; pf-inject's `motion_contract` test is
/// what pins that, for every backend, against these constants.
///
/// Gyro saturates at `i16::MAX / 20` ≈ ±1638 °/s, below a real DualSense's ±2000; accel at
/// ±3.28 g against its ±4 g. Lifting those is a wire-v2 question, not a scale to quietly
/// re-tune here.
pub const MOTION_GYRO_LSB_PER_DEG_S: i32 = 20;
/// See [`MOTION_GYRO_LSB_PER_DEG_S`].
pub const MOTION_ACCEL_LSB_PER_G: i32 = 10_000;
/// What a controller sitting still, face up, actually puts on the wire: **1 g along the UP
/// axis** — which is index 1 — and nothing on the other two.
///
/// This is a measured fact, not a convention we chose. On 2026-08-07 a real DualSense was read
/// over raw HID: at rest it reports `+0.997 g` on report axis 1, and the same session pinned
/// the frame as (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll. The wire is a unit
/// passthrough into that report, so the wire's up axis is the pad's.
///
/// It exists because the alternative is worse than imprecise. A virtual pad that has never
/// received a motion sample used to report `[0, 0, 0]`, and zero acceleration is not "no
/// information" — it is a controller in **free fall**, which is a claim about the physical
/// world that is never true of a pad on a desk. A game deriving orientation from it gets a
/// definite wrong answer instead of a boring right one. `switch_proto`'s neutral has always
/// done this correctly (1 g on its own up axis); the DualSense family and the Deck did not.
///
/// Backends whose units differ rescale this like any other sample rather than hard-coding
/// their own version of 1 g — see `steam_remap::motion_wire_to_deck`.
pub const MOTION_NEUTRAL_ACCEL: [i16; 3] = [0, MOTION_ACCEL_LSB_PER_G as i16, 0];
}
impl InputKind {
+3
View File
@@ -71,6 +71,9 @@ mod pad_audio;
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
mod input;
/// Per-pad motion inter-arrival statistics ([`motion_cadence::MotionCadence`]) — the "gyro feels
/// floaty" measurement, summarized at `info` when a session ends.
mod motion_cadence;
use input::{input_thread, ClientInput};
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
+14 -42
View File
@@ -784,12 +784,9 @@ pub(super) fn input_thread(
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
// declare renderer bits, reaped on remove/teardown below.
let mut pad_streams = PadAudioSlots::new();
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
let mut motion_gaps_us: Vec<u32> = Vec::new();
let mut last_motion: Option<std::time::Instant> = None;
let mut motion_window = std::time::Instant::now();
// Motion-cadence observability, PER PAD and always on — see `motion_cadence`. Summarized at
// `info` when this session ends, which is when a field report is being written.
let mut motion_cadence = super::motion_cadence::MotionCadence::new();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
@@ -848,42 +845,13 @@ pub(super) fn input_thread(
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
// Debug-only instrument: skip the whole thing unless debug logging is actually
// enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop
// regardless, so every session paid for a measurement nobody was reading — and the
// "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption
// about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review
// L-5). The explicit cap below makes it a bound.
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. })
&& tracing::enabled!(tracing::Level::DEBUG)
{
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
// 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop
// for a client that simply sends motion as fast as the link allows.
if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000
{
motion_gaps_us.push(gap.as_micros() as u32);
}
}
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
&& !motion_gaps_us.is_empty()
{
motion_gaps_us.sort_unstable();
let p = |q: f64| {
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
};
tracing::debug!(
samples = motion_gaps_us.len() + 1,
gap_p50_us = p(0.5),
gap_p95_us = p(0.95),
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
"motion cadence (client gyro inter-arrival, 5 s window)"
);
motion_gaps_us.clear();
motion_window = std::time::Instant::now();
}
// Per-pad inter-arrival, unconditionally: one subtraction and one array increment,
// cheap enough that a session no longer has to be re-run with debug logging on to
// answer "is the gyro feed even arriving evenly". The old instrument grew and
// sorted a Vec, which is why it had to be gated — and it shared ONE accumulator
// across pads, so two motion pads measured each other.
if let punktfunk_core::quic::RichInput::Motion { pad, .. } = rich {
motion_cadence.record(pad, std::time::Instant::now());
}
pads.apply_rich(rich);
}
@@ -1169,6 +1137,10 @@ pub(super) fn input_thread(
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
pad_streams.stop_all();
// One line per pad that carried motion. At `info` deliberately: the question it answers
// ("was the gyro feed even arriving evenly?") is asked from a field log after the fact, and a
// measurement that needs the session re-run with debug logging is a measurement nobody gets.
motion_cadence.log_summary();
}
#[cfg(test)]
@@ -0,0 +1,235 @@
//! Per-pad motion inter-arrival statistics — the measurement a "gyro feels floaty" report needs.
//!
//! Gyro aim integrates angular velocity over time, so what a player feels as floaty or jumpy is
//! usually not the samples' values but their spacing: a 250 Hz feed arriving in 40 ms clumps
//! integrates the same total rotation in visibly worse steps. This is the one number that
//! distinguishes "the client stopped sending" from "the link is clumping them" from "we are fine
//! and the problem is elsewhere", and it is cheap enough to keep on always.
//!
//! Two things were wrong with the version this replaces. It kept ONE global accumulator, so two
//! motion-capable pads in a session interleaved into each other's inter-arrival gaps and produced
//! a number that described neither. And it lived at `debug` behind a `tracing::enabled!` check, so
//! a field report arrived with nothing in it and the only way to get the measurement was to ask
//! the user to reproduce with debug logging on.
//!
//! Cost, since it now runs unconditionally: one `Instant` subtraction and one array increment per
//! motion sample. Percentiles come out of a fixed log2 histogram rather than a growing sorted Vec
//! — no allocation, no per-window sort, and no way for a client that streams motion as fast as the
//! link allows to make the instrument expensive. The price is resolution: a reported percentile is
//! the upper bound of its bucket (hence the `_le` suffixes), which is a factor-of-two answer to a
//! question — 4 ms or 40 ms? — whose answers are orders of magnitude apart.
use punktfunk_core::input::MAX_PADS;
use std::time::{Duration, Instant};
/// Log2 buckets over the inter-arrival gap in microseconds: bucket `k` holds gaps in
/// `[2^(k-1), 2^k)` µs, with bucket 0 holding a gap of 0. 22 buckets reach ~2.1 s, past which a
/// gap says "the feed stopped", not "the feed is uneven", and the top bucket saturates.
const BUCKETS: usize = 22;
/// Gaps at or above this are not cadence, they are an interruption — a client that backgrounded,
/// a link that stalled, a session that idled. Counting them would drag every percentile toward a
/// number that describes the interruption instead of the stream, so they are tallied separately.
const STALL_GAP: Duration = Duration::from_millis(500);
/// One pad's cadence accumulator.
#[derive(Clone, Copy, Default)]
struct PadCadence {
last: Option<Instant>,
hist: [u32; BUCKETS],
/// Gaps folded into `hist` (so `samples = gaps + 1` when the pad sent anything at all).
gaps: u64,
/// Largest gap below [`STALL_GAP`], exactly — the histogram's top bucket is too coarse to
/// answer "how bad was the worst one".
max_us: u32,
/// Gaps at or beyond [`STALL_GAP`]: how many times this pad's feed simply stopped and resumed.
stalls: u32,
}
impl PadCadence {
fn record(&mut self, now: Instant) {
let Some(prev) = self.last.replace(now) else {
return; // first sample — no gap yet
};
let gap = now.saturating_duration_since(prev);
if gap >= STALL_GAP {
self.stalls = self.stalls.saturating_add(1);
return;
}
let us = gap.as_micros() as u32;
self.max_us = self.max_us.max(us);
self.gaps = self.gaps.saturating_add(1);
self.hist[bucket(us)] += 1;
}
/// The upper bound (µs) of the bucket the `q`-quantile falls in, or `None` if this pad never
/// produced a gap.
fn percentile_us_le(&self, q: f64) -> Option<u32> {
if self.gaps == 0 {
return None;
}
// The rank of the quantile, 1-based: q=0.5 over 4 gaps is the 2nd.
let want = ((q * self.gaps as f64).ceil() as u64).max(1);
let mut seen = 0u64;
for (k, n) in self.hist.iter().enumerate() {
seen += *n as u64;
if seen >= want {
return Some(bucket_upper_us(k));
}
}
Some(bucket_upper_us(BUCKETS - 1))
}
}
/// The bucket a gap of `us` microseconds falls in — `0` for 0, else `floor(log2(us)) + 1`, capped.
fn bucket(us: u32) -> usize {
if us == 0 {
return 0;
}
((32 - us.leading_zeros()) as usize).min(BUCKETS - 1)
}
/// The exclusive upper bound of bucket `k`, in microseconds (bucket 0 is exactly 0).
fn bucket_upper_us(k: usize) -> u32 {
if k == 0 {
return 0;
}
1u32.checked_shl(k as u32).unwrap_or(u32::MAX)
}
/// Every pad's cadence, keyed by wire index.
pub(super) struct MotionCadence {
pads: [PadCadence; MAX_PADS],
}
impl MotionCadence {
pub(super) fn new() -> MotionCadence {
MotionCadence {
pads: [PadCadence::default(); MAX_PADS],
}
}
/// Note one `RichInput::Motion` for `pad`, arriving now.
pub(super) fn record(&mut self, pad: u8, now: Instant) {
if let Some(p) = self.pads.get_mut(pad as usize) {
p.record(now);
}
}
/// Log one `info` line per pad that carried motion this session. Called once, when the session
/// ends — the point at which a field report is being written and the numbers still exist.
pub(super) fn log_summary(&self) {
for (i, p) in self.pads.iter().enumerate() {
if p.gaps == 0 && p.stalls == 0 {
continue;
}
tracing::info!(
pad = i,
samples = p.gaps + 1,
// 0 = no gap was recorded at all (a pad that sent one sample and then stalled).
gap_p50_us_le = p.percentile_us_le(0.5).unwrap_or(0),
gap_p95_us_le = p.percentile_us_le(0.95).unwrap_or(0),
gap_max_us = p.max_us,
stalls = p.stalls,
"motion cadence for the session (client gyro inter-arrival; percentiles are \
log2-bucket upper bounds, stalls are gaps 500 ms)"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(t0: Instant, ms: u64) -> Instant {
t0 + Duration::from_millis(ms)
}
/// The bug this module exists to fix: two motion pads used to share one accumulator, so each
/// one's gaps were measured against the OTHER's arrivals. Here pad 0 arrives every 4 ms and
/// pad 1 every 40 ms, interleaved — and each must report its own cadence, not the ~2 ms the
/// merged stream would show.
#[test]
fn two_pads_do_not_corrupt_each_others_cadence() {
let t0 = Instant::now();
let mut c = MotionCadence::new();
for i in 0..100u64 {
c.record(0, at(t0, i * 4));
if i % 10 == 0 {
c.record(1, at(t0, i * 4));
}
}
// 4 ms lands in the (2048, 4096] µs bucket; 40 ms in (32768, 65536].
assert_eq!(c.pads[0].percentile_us_le(0.5), Some(4096));
assert_eq!(c.pads[1].percentile_us_le(0.5), Some(65536));
assert_eq!(c.pads[0].gaps, 99);
assert_eq!(c.pads[1].gaps, 9);
}
/// A pad that never sent motion contributes nothing — no line, no gaps.
#[test]
fn a_silent_pad_records_nothing() {
let c = MotionCadence::new();
assert_eq!(c.pads[3].gaps, 0);
assert_eq!(c.pads[3].percentile_us_le(0.5), None);
// One sample is not a gap.
let mut c = MotionCadence::new();
c.record(3, Instant::now());
assert_eq!(c.pads[3].gaps, 0);
}
/// An interruption is not cadence: a backgrounded client's multi-second silence must be
/// counted as a stall rather than dragged through the percentiles, which would otherwise
/// report a healthy 250 Hz feed as a terrible one.
#[test]
fn a_stall_is_counted_separately_from_the_cadence() {
let t0 = Instant::now();
let mut c = MotionCadence::new();
for i in 0..50u64 {
c.record(0, at(t0, i * 4));
}
c.record(0, at(t0, 5_000)); // the client came back after five seconds
for i in 0..50u64 {
c.record(0, at(t0, 5_000 + i * 4));
}
assert_eq!(c.pads[0].stalls, 1);
assert_eq!(
c.pads[0].percentile_us_le(0.95),
Some(4096),
"the stall leaked in"
);
assert!(c.pads[0].max_us < STALL_GAP.as_micros() as u32);
}
/// Percentiles track the tail, which is the half that matters: a feed that is mostly 4 ms but
/// clumps every tenth sample is exactly the "floaty" report, and p50 alone would hide it.
#[test]
fn percentiles_separate_the_body_from_the_tail() {
let t0 = Instant::now();
let mut c = MotionCadence::new();
let mut t = 0u64;
for i in 0..100u64 {
t += if i % 10 == 9 { 40 } else { 4 };
c.record(0, at(t0, t));
}
// 90 gaps of 4 ms, 10 of 40 ms: the body is healthy and the tail is not.
assert_eq!(c.pads[0].percentile_us_le(0.5), Some(4096));
assert_eq!(c.pads[0].percentile_us_le(0.95), Some(65536));
assert!((39_000..=41_000).contains(&c.pads[0].max_us));
}
/// Bucket `k` holds gaps in `[2^(k-1), 2^k)` µs and reports `2^k`.
#[test]
fn bucket_edges() {
assert_eq!(bucket(0), 0);
assert_eq!(bucket(1), 1); // [1, 2)
assert_eq!(bucket(2), 2); // [2, 4)
assert_eq!(bucket(3), 2);
assert_eq!(bucket(4), 3); // [4, 8)
assert_eq!(bucket_upper_us(0), 0);
assert_eq!(bucket_upper_us(1), 2);
assert_eq!(bucket_upper_us(12), 4096); // 4 ms lands here
assert_eq!(bucket(u32::MAX), BUCKETS - 1); // saturates rather than wrapping
}
}
+14
View File
@@ -189,6 +189,20 @@ explicit choice declares your choice — and the host builds each virtual pad fr
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
for instance, or any Sony pad on a Linux host that can't open `/dev/uhid`.
That degrade is the one thing worth knowing about **motion**. An Xbox-class virtual pad has no
gyroscope in its HID contract, so a session that ends up on one throws every motion sample away —
your controller's gyro simply does nothing, which from the couch is indistinguishable from a broken
sensor. Automatic lands there for any controller punktfunk doesn't recognise as Sony or Valve (an
8BitDo with a gyro, say), and so does a Switch Pro streaming to a Windows host, which has no
Nintendo backend to build. **If you want motion, pick a DualSense-class type** — DualSense,
DualSense Edge, DualShock 4, Switch Pro or Steam Deck all carry a motion plane. The clients detect
this case and say so on-screen for a few seconds when it happens; the setting applies from the next
session, not the one you are in.
On a **Steam Deck as the client**, motion also needs Steam Input switched off for punktfunk — with
it on, Steam hands the app its own virtual Xbox pad, which has no gyro to forward no matter which
type you pick.
**Forwarded controller** (*Use controller* on Apple and the console home) — *default: Automatic*,
which forwards *every* connected controller, each as its own player, on Linux, Windows, Apple and the
console home. Pinning one restricts the session to that controller alone — single-player. The Android
+9
View File
@@ -450,6 +450,15 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
host injects it into the matching virtual pad; the Deck's trackpads ride the same touchpad
surface. On the Apple clients rich capture is gated to the DualSense/DualShock 4 family, so
other pads there really do get rumble only.
**But motion only lands if the virtual pad the host builds has somewhere to put it.** The Xbox
360 and Xbox One backends have no gyro in their HID contract, so a session that resolves to one
parses every motion sample and discards it. That is what *Automatic* does for any controller it
doesn't recognise as Sony or Valve — an 8BitDo with a perfectly good gyro included — and it is
also where a Switch Pro lands on a Windows host, which has no `hid-nintendo` backend to fold it
into. Set **Controller type** to a DualSense-class preset to get motion in those cases; the
clients now say so on-screen when they detect it, rather than leaving you to guess why tilting
does nothing. See [Gamepad type](/docs/client-settings#gamepad-type).
3. No desktop client sends pen input, even though the desktop hosts can inject it.
4. All three touch modes exist in the shared code and the picker is there, but nobody has confirmed
them on a Windows 2-in-1. Only meaningful on a touchscreen anyway.
+38
View File
@@ -497,6 +497,25 @@
#define PUNKTFUNK_AXIS_RT 5
// Motion wire units — the DualSense convention, raw `i16` LSBs, carried by
// `RichInput::Motion`. Gyro is angular velocity, accel is proper acceleration.
//
// Every capture path scales *into* these units (`pf-client-core::gamepad`, Swift
// `GamepadWire`, the Android `DeviceGyro`) and every host backend decodes *from* them —
// but the two sides never meet in one crate, which is how a virtual pad shipped for
// months telling its consumers to read the same bytes 40× too fast. The host's virtual
// pads carry fixed calibration blobs, and the resolution a consumer derives from those
// blobs must land back on exactly these numbers; pf-inject's `motion_contract` test is
// what pins that, for every backend, against these constants.
//
// Gyro saturates at `i16::MAX / 20` ≈ ±1638 °/s, below a real DualSense's ±2000; accel at
// ±3.28 g against its ±4 g. Lifting those is a wire-v2 question, not a scale to quietly
// re-tune here.
#define MOTION_GYRO_LSB_PER_DEG_S 20
// See [`MOTION_GYRO_LSB_PER_DEG_S`].
#define MOTION_ACCEL_LSB_PER_G 10000
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
#define PUNKTFUNK_MAGIC 201
@@ -2114,6 +2133,25 @@ typedef struct {
// What a controller sitting still, face up, actually puts on the wire: **1 g along the UP
// axis** — which is index 1 — and nothing on the other two.
//
// This is a measured fact, not a convention we chose. On 2026-08-07 a real DualSense was read
// over raw HID: at rest it reports `+0.997 g` on report axis 1, and the same session pinned
// the frame as (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll. The wire is a unit
// passthrough into that report, so the wire's up axis is the pad's.
//
// It exists because the alternative is worse than imprecise. A virtual pad that has never
// received a motion sample used to report `[0, 0, 0]`, and zero acceleration is not "no
// information" — it is a controller in **free fall**, which is a claim about the physical
// world that is never true of a pad on a desk. A game deriving orientation from it gets a
// definite wrong answer instead of a boring right one. `switch_proto`'s neutral has always
// done this correctly (1 g on its own up axis); the DualSense family and the Deck did not.
//
// Backends whose units differ rescale this like any other sample rather than hard-coding
// their own version of 1 g — see `steam_remap::motion_wire_to_deck`.
#define MOTION_NEUTRAL_ACCEL { 0, (int16_t)MOTION_ACCEL_LSB_PER_G, 0, }
+90 -22
View File
@@ -172,11 +172,21 @@ static DS4_RDESC: [u8; 507] = [
static DS4_FEATURE_PAIRING: [u8; 16] = [ // 0x12 pairing info (MAC at bytes 1..7)
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
// 0x02 IMU calibration. A consumer (SDL's `SDL_hidapi_ps4`, or `hid-playstation` when this pad
// is read on Linux) DERIVES its motion scale from these words rather than assuming one: gyro
// resolution = (|pitch_plus| + |pitch_minus|) / (speed_plus + speed_minus) LSB per °/s, accel
// resolution = (acc_plus - acc_minus) / 2 LSB per g. So this blob is where the wire contract
// (20 LSB/°·s, 10000 LSB/g) is declared on the DS4 device type, and it must state exactly what
// the wire delivers — the pre-2026-08 values (±16 / speed 32 / ±8192) declared 0.5 LSB/°·s and
// 8192 LSB/g, i.e. every DS4 session read gyro 40× too fast and accel 1.22× hot.
// Mirrors inject/proto/dualshock4_proto.rs DS4_FEATURE_CALIBRATION; this WDK workspace can't
// depend on pf-inject, so pf-inject's `motion_contract` test parses THIS file and re-derives the
// units from it. Keep the two in sync.
#[rustfmt::skip]
static DS4_FEATURE_CALIBRATION: [u8; 37] = [ // 0x02 IMU calibration
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xF0, 0xFF, 0x10, 0x00, 0xF0, 0xFF, 0x10,
0x00, 0xF0, 0xFF, 0x20, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x20, 0x00, 0xE0, 0x00,
0x20, 0x00, 0xE0, 0x00, 0x00,
static DS4_FEATURE_CALIBRATION: [u8; 37] = [
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x00, 0x00,
];
#[rustfmt::skip]
static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info
@@ -327,6 +337,44 @@ const OFF_OUT_RING_VER: usize = core::mem::offset_of!(PadShm, out_ring_ver);
const OFF_RING_HEAD: usize = core::mem::offset_of!(PadShm, ring_head);
const OFF_OUT_RING_LEN: usize = core::mem::offset_of!(PadShm, out_ring_len);
const OFF_OUT_RING: usize = core::mem::offset_of!(PadShm, out_ring);
const OFF_INPUT_GEN: usize = core::mem::offset_of!(PadShm, input_gen);
/// How many timer ticks separate two runs of the channel/health housekeeping. The tick itself is
/// [`TIMER_PERIOD_MS`]; the pump, the `driver_proto` stamp and the heartbeat keep their historical
/// ~8 ms cadence so nothing that watches them changes rate — only the input path got faster.
const PUMP_EVERY_N_TICKS: u32 = 4;
/// Timer period. Was 8 ms, which — with one pended READ_REPORT completed per tick — capped what a
/// game could observe at ~125 Hz and added up to 8 ms of latency, while clients stream motion at
/// ~250 Hz. 2 ms is about a real DualShock 4's Bluetooth cadence and leaves headroom above the
/// client rate; the extra ticks only do the cheap half (read the input slot, complete one pended
/// read), see [`PUMP_EVERY_N_TICKS`].
const TIMER_PERIOD_MS: u32 = 2;
/// Read the host's input report out of the section under the v2.3 seqlock, so a report caught
/// mid-copy is retried instead of handed to a game.
///
/// The host takes `input_gen` odd before writing the 64 bytes and even after, so an odd sample or
/// a changed one means the read straddled a write. One retry: the host publishes in microseconds
/// and this runs on a 2 ms timer, so a second collision is not a thing that happens, and if it did,
/// re-serving the previous whole report beats serving a torn one.
///
/// Against a pre-v2.3 host the field is never written, so it reads 0 — constant and even — and
/// this accepts on the first pass, exactly as the driver behaved before the seqlock existed.
/// `false` means "no whole report available"; the caller keeps what it had.
fn read_input_report(view: &pf_umdf_util::section::MappedView, buf: &mut [u8; 64]) -> bool {
for _ in 0..2 {
let before = view.load_u32(OFF_INPUT_GEN, Ordering::Acquire);
if !before.is_multiple_of(2) {
continue; // a write is in flight right now
}
view.read_bytes(OFF_INPUT, buf);
// Acquire: the body reads above must not sink below this sample of the generation.
if view.load_u32(OFF_INPUT_GEN, Ordering::Acquire) == before {
return true;
}
}
false
}
const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22;
@@ -413,6 +461,9 @@ static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
/// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]);
/// `u32::MAX` = not resolved. See [`device_type`] for why this exists.
static PNP_DEVTYPE: AtomicU32 = AtomicU32::new(u32::MAX);
/// Timer ticks since load — picks the [`PUMP_EVERY_N_TICKS`] ticks that also do the channel
/// handshake and health marks. Wrapping is fine: only its residue matters.
static TICK: AtomicU32 = AtomicU32::new(0);
/// Map a devnode's hardware-id list (lowercase, `;`-separated — see
/// [`wdf::query_hardware_ids`](pf_umdf_util::wdf::query_hardware_ids)) to the `device_type` the host
@@ -649,7 +700,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
tcfg.Size = core::mem::size_of::<WDF_TIMER_CONFIG>() as ULONG;
tcfg.EvtTimerFunc = Some(evt_timer);
tcfg.Period = 8; // ms
tcfg.Period = TIMER_PERIOD_MS;
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
// SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the
// fields we use below.
@@ -669,8 +720,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
dbglog!("[pf-gamepad] WdfTimerCreate failed 0x{:08x}", st as u32);
return st;
}
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
let due = -(TIMER_PERIOD_MS as i64) * 10_000;
// SAFETY: timer valid; the due time is TIMER_PERIOD_MS in 100 ns units, negative = relative.
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, due) };
log("[pf-gamepad] device ready (DualSense 054C:0CE6)");
STATUS_SUCCESS
@@ -1034,27 +1086,43 @@ fn device_type() -> u8 {
}
extern "C" fn evt_timer(timer: WDFTIMER) {
// One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the
// latest host input report from the attached DATA section (all safe, via pf_umdf_util).
match CHANNEL.pump(&channel_cfg()) {
// Two cadences on one timer. EVERY tick ([`TIMER_PERIOD_MS`]) does the cheap input half —
// read the section's report slot, complete one pended READ_REPORT — because that pair is what
// bounds the rate a game can observe, and at the old 8 ms it halved a 250 Hz motion stream.
// The channel handshake and the health marks stay on their historical ~8 ms
// ([`PUMP_EVERY_N_TICKS`]): they cost more, nothing about them wants to be faster, and the
// heartbeat's documented "+1 per ~8 ms tick" is what the host reads as liveness.
let tick = TICK.fetch_add(1, Ordering::Relaxed);
let housekeeping = tick.is_multiple_of(PUMP_EVERY_N_TICKS);
let view = if housekeeping {
// Publish our pid / adopt a delivery / detect host-gone.
CHANNEL.pump(&channel_cfg())
} else {
CHANNEL.data()
};
match view {
Some(view) => {
// Keep the fallback identity fresh: `device_type()`'s last resort (channel detached,
// no PnP match) reads LAST_DEVTYPE, and this tick is the one place that always sees
// the attached section.
LAST_DEVTYPE.store(view.read_u8(OFF_DEVICE_TYPE) as u32, Ordering::Relaxed);
let mut buf = [0u8; 64];
view.read_bytes(OFF_INPUT, &mut buf);
if buf[0] == 0x01
// A torn read is dropped rather than served: `read_input_report` returns false only
// when it caught the host mid-publish, and the previous whole report stays in place.
if read_input_report(view, &mut buf)
&& buf[0] == 0x01
&& let Ok(mut g) = INPUT_REPORT.lock()
{
*g = buf;
}
// Health marks the host watches: driver_proto (attach signal, idempotent) and
// driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
// and alive" apart from "driver package missing/failed to bind".
view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
if housekeeping {
// Keep the fallback identity fresh: `device_type()`'s last resort (channel
// detached, no PnP match) reads LAST_DEVTYPE, and this tick is the one place that
// always sees the attached section.
LAST_DEVTYPE.store(view.read_u8(OFF_DEVICE_TYPE) as u32, Ordering::Relaxed);
// Health marks the host watches: driver_proto (attach signal, idempotent) and
// driver_heartbeat (+1 per ~8 ms = liveness). Lets the host tell "driver bound and
// alive" apart from "driver package missing/failed to bind".
view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
}
}
None => {
// Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral
+16 -9
View File
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material";
import Section from "@unom/ui/section";
import { useEffect } from "react";
import { DialogsProvider } from "../src/components/dialogs";
// React Query is present so any query-backed component mounts without a real
// host. Stories should feed mock data rather than fetch — retries are off so a
@@ -51,15 +52,21 @@ export default definePreview({
return (
<QueryClientProvider client={queryClient}>
<MaterialProvider theme={defaultMaterialTheme}>
<div className={dark ? "dark" : ""}>
<Section maxWidth={false}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</Section>
</div>
{/* The console's confirm/prompt, same as __root mounts. Any section that can
delete or discard something calls `useDialogs()`, which THROWS without
this so it belongs beside the other app-level providers rather than in
each story's decorators. */}
<DialogsProvider>
<div className={dark ? "dark" : ""}>
<Section maxWidth={false}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</Section>
</div>
</DialogsProvider>
</MaterialProvider>
</QueryClientProvider>
);
+24 -9
View File
@@ -225,6 +225,7 @@
"display_layout_manual": "Manuell",
"display_preset_custom_label": "Eigene Voreinstellungen",
"display_preset_save_as": "Als Voreinstellung speichern…",
"display_preset_save_title": "Als Voreinstellung speichern",
"display_preset_name": "Name der Voreinstellung",
"display_preset_edit": "Umbenennen",
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
@@ -254,7 +255,8 @@
"pairing_native_cancel": "Abbrechen",
"pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln?",
"pairing_native_unpair_body": "Es muss sich erneut koppeln, um zu verbinden.",
"pairing_protocol": "Protokoll",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
@@ -262,7 +264,8 @@
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben",
"pairing_pending_deny": "Ablehnen",
"pairing_pending_name_prompt": "Gerät benennen:",
"pairing_pending_name_prompt": "Gerät benennen",
"pairing_pending_name_title": "Dieses Gerät zulassen",
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
@@ -324,7 +327,8 @@
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen?",
"library_provider_purge_body": "Das entfernt sie nur aus der Bibliothek.",
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
@@ -332,13 +336,18 @@
"library_cancel": "Abbrechen",
"library_edit": "Bearbeiten",
"library_delete": "Löschen",
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
"library_delete_confirm": "Dieses eigene Spiel löschen?",
"library_delete_body": "Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
"settings_language": "Sprache",
"common_loading": "Wird geladen…",
"common_error": "Etwas ist schiefgelaufen.",
"common_retry": "Erneut versuchen",
"common_cancel": "Abbrechen",
"common_confirm": "Bestätigen",
"common_save": "Speichern",
"common_discard": "Verwerfen",
"common_remove": "Entfernen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden",
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
@@ -412,7 +421,8 @@
"stats_view": "Ansehen",
"stats_download": "Herunterladen",
"stats_delete": "Löschen",
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
"stats_delete_confirm": "Diese Aufnahme löschen?",
"stats_delete_body": "Das kann nicht rückgängig gemacht werden.",
"stats_detail_title": "Aufnahme-Details",
"stats_close": "Schließen",
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
@@ -462,7 +472,8 @@
"store_stopped": "Läuft nicht",
"store_version_unknown": "Version unbekannt",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_confirm": "{title} deinstallieren?",
"store_uninstall_body": "Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
"store_sources_title": "Katalogquellen",
@@ -477,7 +488,8 @@
"store_source_fetched": "zuletzt geladen {when}",
"store_source_never": "nie",
"store_source_remove": "Quelle entfernen",
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen?",
"store_source_remove_body": "Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
"store_add_source_title": "Katalogquelle hinzufügen",
@@ -536,8 +548,11 @@
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?",
"action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?",
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele.",
"games_end_all_waiting_title": "Alle {count} wartenden Spiele beenden?",
"action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese.",
"action_stop_session_all_title": "Jede laufende Sitzung beenden?",
"action_stop_session_all": "Alle Sitzungen beenden",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
+24 -9
View File
@@ -225,6 +225,7 @@
"display_layout_manual": "Manual",
"display_preset_custom_label": "Custom presets",
"display_preset_save_as": "Save as preset…",
"display_preset_save_title": "Save as preset",
"display_preset_name": "Preset name",
"display_preset_edit": "Rename",
"display_preset_update": "Update to current settings",
@@ -254,7 +255,8 @@
"pairing_native_cancel": "Cancel",
"pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
"pairing_native_unpair_confirm": "Unpair this device?",
"pairing_native_unpair_body": "It will need to pair again to connect.",
"pairing_protocol": "Protocol",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
@@ -262,7 +264,8 @@
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve",
"pairing_pending_deny": "Deny",
"pairing_pending_name_prompt": "Name this device:",
"pairing_pending_name_prompt": "Name this device",
"pairing_pending_name_title": "Approve this device",
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
@@ -324,7 +327,8 @@
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”?",
"library_provider_purge_body": "This only removes them from the library.",
"library_provider_purged": "Removed the entries synced by “{provider}”.",
"library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
@@ -332,13 +336,18 @@
"library_cancel": "Cancel",
"library_edit": "Edit",
"library_delete": "Delete",
"library_delete_confirm": "Delete this custom game? This can't be undone.",
"library_delete_confirm": "Delete this custom game?",
"library_delete_body": "This can't be undone.",
"settings_title": "Settings",
"settings_language": "Language",
"common_loading": "Loading…",
"common_error": "Something went wrong.",
"common_retry": "Retry",
"common_cancel": "Cancel",
"common_confirm": "Confirm",
"common_save": "Save",
"common_discard": "Discard",
"common_remove": "Remove",
"common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in",
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
@@ -412,7 +421,8 @@
"stats_view": "View",
"stats_download": "Download",
"stats_delete": "Delete",
"stats_delete_confirm": "Delete this recording? This can't be undone.",
"stats_delete_confirm": "Delete this recording?",
"stats_delete_body": "This can't be undone.",
"stats_detail_title": "Recording detail",
"stats_close": "Close",
"stats_no_samples": "This recording has no samples.",
@@ -462,7 +472,8 @@
"store_stopped": "Not running",
"store_version_unknown": "version unknown",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
"store_uninstall_confirm": "Uninstall {title}?",
"store_uninstall_body": "You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
"store_sources_title": "Catalog sources",
@@ -477,7 +488,8 @@
"store_source_fetched": "last fetched {when}",
"store_source_never": "never",
"store_source_remove": "Remove source",
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
"store_source_remove_confirm": "Remove the source “{name}”?",
"store_source_remove_body": "Plugins already installed from it stay installed.",
"store_source_remove_failed": "Could not remove the source.",
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
"store_add_source_title": "Add a catalog source",
@@ -536,8 +548,11 @@
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?",
"action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?",
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close.",
"games_end_all_waiting_title": "End all {count} waiting games?",
"action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one.",
"action_stop_session_all_title": "Stop every live session?",
"action_stop_session_all": "Stop every session",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
+5 -13
View File
@@ -12,11 +12,12 @@ import {
Settings,
Workflow,
} from "lucide-react";
import { motion, stagger } from "motion/react";
import { motion } from "motion/react";
import { type ReactNode, useState } from "react";
import { useHostEvents } from "@/api/events";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark";
import { Stagger, staggerProps } from "@/components/stagger";
import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
@@ -72,10 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<motion.nav
animate="enter"
initial="from"
transition={{
delayChildren: stagger(0.1),
}}
variants={{ enter: {}, from: {} }}
{...staggerProps()}
className="flex flex-col gap-1"
>
{NAV.map(({ to, icon: Icon, label }) => (
@@ -148,13 +146,7 @@ function PluginNavSection() {
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<Stagger root className="mt-6 flex flex-col gap-1">
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
@@ -193,7 +185,7 @@ function PluginNavSection() {
</motion.div>
);
})}
</motion.div>
</Stagger>
);
}
+209
View File
@@ -0,0 +1,209 @@
import {
createContext,
type FC,
type ReactNode,
useContext,
useMemo,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* `window.confirm` / `window.prompt`, replaced by the console's own Dialog.
*
* WHY A PROVIDER AND NOT A COMPONENT PER SITE. The native calls this replaces are *expressions*
* `if (!confirm(…)) return;` sixteen of them, threaded through mutation handlers and one router
* navigation guard. Rewriting each into "hold the pending action in state, render a dialog, run the
* action from its onConfirm" would have put a piece of dialog machinery in every section file and
* turned each linear handler inside out. Handing back a PROMISE keeps the call sites the shape they
* already are:
*
* if (!(await confirm({ title: }))) return;
*
* which is also why the navigation guard could come along: TanStack's `shouldBlockFn` accepts
* `Promise<boolean>`. The one native prompt that necessarily stays is `beforeunload` a reload or
* a tab close is the browser's dialog to draw, not ours.
*/
export type ConfirmOptions = {
/** The question. Short — it is a heading. */
title: string;
/** What the operator is not being told by the title alone: the consequence. */
description?: string;
/** Affirmative label. Use the same verb as the control that opened the dialog. */
confirmLabel?: string;
/** Paint the affirmative button red — anything that destroys, removes, or interrupts. */
destructive?: boolean;
};
export type PromptOptions = {
title: string;
description?: string;
confirmLabel?: string;
/** Field label. */
label: string;
defaultValue?: string;
placeholder?: string;
};
type Dialogs = {
/** Resolves true only if the operator confirmed; false on cancel, Esc, or overlay click. */
confirm: (options: ConfirmOptions) => Promise<boolean>;
/** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */
promptText: (options: PromptOptions) => Promise<string | null>;
};
type Pending =
| { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void }
| {
kind: "prompt";
options: PromptOptions;
settle: (text: string | null) => void;
};
const DialogsContext = createContext<Dialogs | null>(null);
/** Settle a request negatively. Must switch on `kind` — the two `settle`s take different types. */
const cancel = (p: Pending | null) => {
if (!p) return;
if (p.kind === "confirm") p.settle(false);
else p.settle(null);
};
export const DialogsProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [pending, setPending] = useState<Pending | null>(null);
const [draft, setDraft] = useState("");
// `setPending` takes the updater form so a second ask while one is already open cannot strand the
// first promise unresolved — the caller would await forever. Two at once shouldn't happen (the
// open dialog is modal), but the navigation guard can fire from outside the page's own UI.
const api = useMemo<Dialogs>(
() => ({
confirm: (options) =>
new Promise<boolean>((resolve) =>
setPending((prev) => {
cancel(prev);
return { kind: "confirm", options, settle: resolve };
}),
),
promptText: (options) =>
new Promise<string | null>((resolve) => {
setDraft(options.defaultValue ?? "");
setPending((prev) => {
cancel(prev);
return { kind: "prompt", options, settle: resolve };
});
}),
}),
[],
);
/** Resolve and close. Every exit — button, Esc, overlay — goes through here exactly once. */
const close = (value: boolean | string | null) => {
if (!pending) return;
if (pending.kind === "confirm") pending.settle(value === true);
else pending.settle(typeof value === "string" ? value : null);
setPending(null);
};
const cancelled = () => close(pending?.kind === "prompt" ? null : false);
const options = pending?.options;
return (
<DialogsContext.Provider value={api}>
{children}
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) cancelled();
}}
>
{pending && options && (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
{/* Radix warns when a dialog has no description; render the element only when
there is one to say, and tell it so explicitly otherwise. */}
{options.description ? (
<DialogDescription>{options.description}</DialogDescription>
) : (
<DialogDescription className="sr-only">
{options.title}
</DialogDescription>
)}
</DialogHeader>
{pending.kind === "prompt" && (
<div className="space-y-2">
<Label htmlFor="dialog-prompt">{pending.options.label}</Label>
<Input
id="dialog-prompt"
// Autofocus is right here and nowhere else: a modal text prompt exists
// to be typed into, and it took the focus automatically as a `prompt()`.
autoFocus
autoComplete="off"
value={draft}
placeholder={pending.options.placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter submits — the reflex `prompt()` trained everyone into.
if (e.key === "Enter") {
e.preventDefault();
close(draft);
}
}}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={cancelled}>
{m.common_cancel()}
</Button>
<Button
variant={
pending.kind === "confirm" && pending.options.destructive
? "destructive"
: "default"
}
onClick={() => close(pending.kind === "prompt" ? draft : true)}
>
{options.confirmLabel ??
(pending.kind === "prompt"
? m.common_save()
: m.common_confirm())}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</DialogsContext.Provider>
);
};
/**
* The console's `confirm` / `prompt`. Both return a promise, so a handler reads top to bottom:
*
* const onDelete = async () => {
* if (!(await confirm({ title: m.x(), confirmLabel: m.y(), destructive: true }))) return;
* await remove.mutateAsync();
* };
*/
export const useDialogs = (): Dialogs => {
const ctx = useContext(DialogsContext);
if (!ctx)
throw new Error(
"useDialogs must be used inside <DialogsProvider> (__root)",
);
return ctx;
};
+55
View File
@@ -0,0 +1,55 @@
import { type HTMLMotionProps, motion, stagger } from "motion/react";
import type { FC } from "react";
/** The house cadence, in seconds between siblings. */
export const STAGGER_GAP = 0.1;
/**
* The stagger-container contract as plain props for the places that need a specific element
* (`motion.nav`) rather than the `<Stagger>` div below.
*
* The empty `enter`/`from` variants are not a placeholder: a motion element only PROPAGATES a
* variant it names, so a container that defines none stops the cascade dead and its children never
* animate at all.
*/
export const staggerProps = (gap: number = STAGGER_GAP) => ({
variants: { enter: {}, from: {} },
transition: { delayChildren: stagger(gap) },
});
/**
* The console's on-mount cadence: siblings arrive one after another, not all on the same frame.
*
* WHY THIS EXISTS AS A COMPONENT. Every animated primitive here (`Card`, `Button`, `Checkbox` all
* `@unom/ui`) is a motion element whose `from`/`enter` variants are INHERITED from the nearest
* motion ancestor, and it is that ancestor which owns the timing. `@unom/ui`'s `<Section>` sets
* `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the
* Section staggers for free.
*
* The trap is that an `<AnimatedCard>` is ALSO a motion element, and it sets no `delayChildren` so
* a grid of cards nested inside a card becomes its own timing group and every tile lands at once.
* Nothing in the types catches that; it only shows up in a browser, beside a page that does it
* right. Wrapping the grid re-establishes the cadence.
*
* `root` is for a container with no animating motion ancestor to inherit from (the sidebar nav): it
* drives `from → enter` itself. Inside a `<Section>` or a card, leave it off supplying
* `initial`/`animate` there would run the group on its own clock instead of the page's.
*/
export const Stagger: FC<
HTMLMotionProps<"div"> & {
/** Seconds between siblings. */
gap?: number;
/** Drive the enter animation instead of inheriting it — see above. */
root?: boolean;
}
> = ({ gap, root = false, transition, ...props }) => {
const base = staggerProps(gap);
return (
<motion.div
{...(root ? { initial: "from", animate: "enter" } : {})}
variants={base.variants}
transition={{ ...base.transition, ...transition }}
{...props}
/>
);
};
+7
View File
@@ -0,0 +1,7 @@
// The console's Checkbox IS @unom/ui's radix checkbox — shadcn-compatible tokens (border-input,
// data-checked:bg-primary), the tick drawn as an animated path, plus the shared toggle sound.
//
// It is `checked`/`onCheckedChange` (radix), NOT `checked`/`onChange` — a raw `<input
// type="checkbox">` swapped in here silently loses the brand entirely, which is exactly how two of
// them survived in the library forms.
export { Checkbox } from "@unom/ui/form/checkbox";
+16
View File
@@ -0,0 +1,16 @@
// The console's numeric field IS @unom/ui's form input-number — the Input with a number's rules
// layered on: it keeps a local draft while you type (so the field can be EMPTY on the way to a new
// value), commits only a finite in-range number, and clamps to `min`/`max` on blur.
//
// That is not cosmetic. The hand-rolled shape it replaces —
//
// <Input type="number" min={1} max={600}
// onChange={(e) => set({ timeout_s: Number(e.target.value) || 30 })} />
//
// — has two defects the shared component doesn't: clearing the field snaps it to the fallback
// instead of letting you retype, and `min`/`max` are decoration (an `<input>`'s range is only
// enforced by form validation, which a controlled field like this never runs) — so 900 went
// straight into a timeout capped at 600.
//
// `onChange` hands you a `number`, not an event.
export { InputNumber } from "@unom/ui/form/input-number";
+72
View File
@@ -0,0 +1,72 @@
// The console's Select IS @unom/ui's radix select, with the same two corrections the Tabs wrapper
// needs — @unom/ui's palette names don't all mean the same thing in this app's token set:
//
// • `text-secondary`, which the trigger uses for BOTH the placeholder and the chevron, is a
// *text* colour upstream. Here `--secondary` is a SURFACE (#241c3d dark / #ece6fb light), so the
// chevron rendered at near-zero contrast against the card it sits on — a select that looked like
// a plain box with no affordance at all. `text-muted-foreground` is this app's "quiet text".
// • `border-main` is the foreground colour — a near-white 1px border in dark, which would make a
// select shout next to the `border-input` used by every Input beside it.
//
// The trigger also defaults to `w-full` (upstream is `w-fit`) and to the Input's `rounded-md`: in
// this console a select is a form field in a stacked column, never an inline chip.
//
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
// tokens once, rather than restyling it at every call site.
import {
Select,
SelectContent,
SelectGroup,
SelectItem as SelectItemBase,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger as SelectTriggerBase,
SelectValue,
} from "@unom/ui/form/select";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const SelectTrigger = ({
className,
...props
}: ComponentProps<typeof SelectTriggerBase>) => (
<SelectTriggerBase
className={cn(
"w-full rounded-md border-input data-placeholder:text-muted-foreground",
"[&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
/>
);
SelectTrigger.displayName = "SelectTrigger";
// The highlighted row. Upstream paints it `bg-main/25` — a neutral grey wash, since `--main` is the
// foreground colour. Everywhere else in this console the "this is the one" wash is brand violet
// (`bg-primary/15` on the nav's hover and active states, `ring-primary` on a chosen preset card), so
// a grey row is the odd one out the moment a select sits next to any of them.
const SelectItem = ({
className,
...props
}: ComponentProps<typeof SelectItemBase>) => (
<SelectItemBase
className={cn("focus:bg-primary/15 focus:text-foreground", className)}
{...props}
/>
);
SelectItem.displayName = "SelectItem";
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+4
View File
@@ -0,0 +1,4 @@
// The console's Textarea IS @unom/ui's form textarea — the Input's twin (same `border-input` /
// `placeholder:text-muted-foreground` / focus ring tokens, material gloss via UnomProviders), so a
// multi-line field sits beside a single-line one without either looking borrowed.
export { Textarea } from "@unom/ui/form/textarea";
+12 -6
View File
@@ -13,6 +13,7 @@ import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
import { DialogsProvider } from "@/components/dialogs";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
@@ -68,13 +69,18 @@ function RootComponent() {
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* The console's own confirm/prompt, in place of the browser's grey boxes. Mounted
at the root because the navigation guard on the Displays page asks for one
while LEAVING that page see components/dialogs.tsx. */}
<DialogsProvider>
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</DialogsProvider>
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
+30 -23
View File
@@ -1,7 +1,7 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { type FC, useEffect, useState } from "react";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -11,7 +11,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { m } from "@/paraglide/messages";
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
@@ -104,18 +112,21 @@ export const HookForm: FC<{
<div className="space-y-2">
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
<select
id="hook-on"
value={draft.on}
onChange={(e) => set({ on: e.target.value })}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{EVENT_KINDS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
{/* The event kinds are IDENTIFIERS, not prose deliberately not routed through
i18n, and shown in the host's own `domain.event` spelling so what you pick here
reads the same as what you'd type into the SSE `?kinds=` filter. */}
<Select value={draft.on} onValueChange={(on) => set({ on })}>
<SelectTrigger id="hook-on">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{m.automation_field_on_help()}
</p>
@@ -222,14 +233,11 @@ export const HookForm: FC<{
<Label htmlFor="hook-debounce">
{m.automation_field_debounce()}
</Label>
<Input
<InputNumber
id="hook-debounce"
type="number"
min={0}
value={draft.debounce_ms ?? 0}
onChange={(e) =>
set({ debounce_ms: Number(e.target.value) || 0 })
}
onChange={(debounce_ms) => set({ debounce_ms })}
/>
</div>
{kind === "run" && (
@@ -237,15 +245,14 @@ export const HookForm: FC<{
<Label htmlFor="hook-timeout">
{m.automation_field_timeout()}
</Label>
<Input
{/* 600 is the host's own ceiling the old field let 900 through, and the
hook then failed at run time rather than at the point of typing it. */}
<InputNumber
id="hook-timeout"
type="number"
min={1}
max={600}
value={draft.timeout_s ?? 30}
onChange={(e) =>
set({ timeout_s: Number(e.target.value) || 30 })
}
onChange={(timeout_s) => set({ timeout_s })}
/>
</div>
)}
+9 -2
View File
@@ -6,6 +6,7 @@ import { ApiError } from "@/api/fetcher";
import { useGetHooks } from "@/api/gen/hooks/hooks";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ import { HookForm } from "./HookForm";
*/
export const SectionAutomation: FC = () => {
useLocale();
const { confirm } = useDialogs();
const query = useGetHooks();
const save = useSaveHooks();
@@ -70,8 +72,13 @@ export const SectionAutomation: FC = () => {
setEditing(null);
};
const remove = (index: number) => {
if (!confirm(m.automation_delete_confirm())) return;
const remove = async (index: number) => {
const ok = await confirm({
title: m.automation_delete_confirm(),
confirmLabel: m.automation_delete(),
destructive: true,
});
if (!ok) return;
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
};
+23 -13
View File
@@ -9,6 +9,7 @@ import {
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { useDialogs } from "@/components/dialogs";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -17,6 +18,7 @@ import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
useLocale();
const qc = useQueryClient();
const { confirm } = useDialogs();
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
@@ -61,23 +63,26 @@ export const SectionDashboard: FC = () => {
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
* row for an operator-typed command carries no `app_id` so that row ended all of them.
*/
const onEndGame = (game: ActiveGame) => {
const onEndGame = async (game: ActiveGame) => {
const games = status.data?.games ?? [];
if (game.state === "grace") {
const waiting = games.filter((g) => g.state === "grace").length;
if (
!game.app_id &&
waiting > 1 &&
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
)
return;
if (!game.app_id && waiting > 1) {
const ok = await confirm({
title: m.games_end_all_waiting_title({ count: waiting }),
description: m.games_end_all_waiting_confirm({ count: waiting }),
confirmLabel: m.games_end_now(),
destructive: true,
});
if (!ok) return;
}
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
);
return;
}
if (!confirmStopAll()) return;
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
@@ -86,18 +91,23 @@ export const SectionDashboard: FC = () => {
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
* session live, stopping is not a per-client action and the operator has to know that. */
const confirmStopAll = (): boolean => {
const confirmStopAll = (): Promise<boolean> => {
const active = status.data?.active_sessions ?? 0;
if (active <= 1) return true;
return confirm(m.action_stop_session_all_confirm({ count: active }));
if (active <= 1) return Promise.resolve(true);
return confirm({
title: m.action_stop_session_all_title(),
description: m.action_stop_session_all_confirm({ count: active }),
confirmLabel: m.action_stop_session_all(),
destructive: true,
});
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => {
if (!confirmStopAll()) return;
onStopSession={async () => {
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
+77 -44
View File
@@ -1,6 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
@@ -37,10 +36,13 @@ import type {
Preset,
Topology,
} from "@/api/gen/model";
import { type ConfirmOptions, useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
@@ -54,6 +56,7 @@ import { m } from "@/paraglide/messages";
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
@@ -163,8 +166,12 @@ export const DisplaySection: FC = () => {
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
//
// `shouldBlockFn` may return a promise, which is what lets this ask with the console's own
// dialog rather than the browser's. `enableBeforeUnload` stays native by necessity: a reload or
// a tab close is the browser's dialog to draw, and it will not wait on ours.
useBlocker({
shouldBlockFn: () => !confirm(m.display_discard_confirm()),
shouldBlockFn: async () => !(await confirm(discardPrompt())),
enableBeforeUnload: () => dirty,
disabled: !dirty,
});
@@ -233,6 +240,17 @@ export const DisplaySection: FC = () => {
);
};
/**
* The gate on anything that would throw unsaved Custom fields away asked from three places (a
* preset click, applying a saved preset, and leaving the page), so it is written once. A function
* rather than a constant because the message is resolved per locale, at call time.
*/
const discardPrompt = (): ConfirmOptions => ({
title: m.display_discard_confirm(),
confirmLabel: m.common_discard(),
destructive: true,
});
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
const PRESET_ORDER = [
"default",
@@ -243,7 +261,11 @@ const PRESET_ORDER = [
"custom",
] as const;
const DisplayForm: FC<{
/**
* The policy form itself pure, so Storybook can render it (with the `<Card>` wrapper the page puts
* around it, which is also its motion parent) without a host answering `/display/settings`.
*/
export const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
@@ -279,6 +301,7 @@ const DisplayForm: FC<{
error,
}) => {
const qc = useQueryClient();
const { confirm, promptText } = useDialogs();
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
const deletePreset = useDeleteCustomPreset();
@@ -309,11 +332,10 @@ const DisplayForm: FC<{
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save).
const pickPreset = (id: string) => {
const pickPreset = async (id: string) => {
// A preset click overwrites the whole policy, so hand-edits that were never saved would
// vanish without a word — the same failure as not finding the Save button, one click later.
if (dirty && id !== "custom" && !confirm(m.display_discard_confirm()))
return;
if (dirty && id !== "custom" && !(await confirm(discardPrompt()))) return;
// Already hand-editing: re-seeding would fill in defaults for fields the stored policy
// leaves unset and flag "unsaved changes" for a click that changed nothing.
if (id === "custom" && isCustom) return;
@@ -344,8 +366,8 @@ const DisplayForm: FC<{
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
const applyCustomPreset = (p: CustomPreset) => {
if (dirty && !confirm(m.display_discard_confirm())) return;
const applyCustomPreset = async (p: CustomPreset) => {
if (dirty && !(await confirm(discardPrompt()))) return;
apply({
version: 1,
preset: "custom",
@@ -372,8 +394,13 @@ const DisplayForm: FC<{
const anyCustomSelected = customPresets.some(customSelected);
// Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset.
const saveAsPreset = () => {
const name = prompt(m.display_preset_name())?.trim();
const saveAsPreset = async () => {
const name = (
await promptText({
title: m.display_preset_save_title(),
label: m.display_preset_name(),
})
)?.trim();
if (!name) return; // cancelled or empty
createPreset.mutate(
{
@@ -386,8 +413,14 @@ const DisplayForm: FC<{
{ onSuccess: invalidateSettings },
);
};
const renamePreset = (p: CustomPreset) => {
const name = prompt(m.display_preset_name(), p.name)?.trim();
const renamePreset = async (p: CustomPreset) => {
const name = (
await promptText({
title: m.display_preset_edit(),
label: m.display_preset_name(),
defaultValue: p.name,
})
)?.trim();
if (!name) return;
updatePreset.mutate(
{
@@ -413,8 +446,13 @@ const DisplayForm: FC<{
},
{ onSuccess: invalidateSettings },
);
const removePreset = (p: CustomPreset) => {
if (!confirm(m.display_preset_delete_confirm())) return;
const removePreset = async (p: CustomPreset) => {
const ok = await confirm({
title: m.display_preset_delete_confirm(),
confirmLabel: m.display_preset_delete(),
destructive: true,
});
if (!ok) return;
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
};
@@ -431,7 +469,11 @@ const DisplayForm: FC<{
<Label className="mb-1 block text-base font-semibold">
{m.display_preset()}
</Label>
<div className="grid gap-3 sm:grid-cols-2">
{/* The preset tiles are cards nested INSIDE this page's config card, so their motion
parent is that card which sets no `delayChildren` and therefore landed all six
on the same frame, unlike every other card grid in the console. `Stagger` gives
the group its own cadence back (see components/stagger.tsx). */}
<Stagger className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields;
@@ -506,7 +548,7 @@ const DisplayForm: FC<{
</Card>
);
})}
</div>
</Stagger>
</div>
{/* Custom presets the operator's saved field-bundles, rendered like the built-ins but
@@ -527,7 +569,7 @@ const DisplayForm: FC<{
</Button>
</div>
{customPresets.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
<Stagger className="grid gap-3 sm:grid-cols-2">
{customPresets.map((p) => (
<CustomPresetCard
key={p.id}
@@ -540,7 +582,7 @@ const DisplayForm: FC<{
onDelete={() => removePreset(p)}
/>
))}
</div>
</Stagger>
)}
{presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500">
@@ -609,16 +651,14 @@ const DisplayForm: FC<{
</Button>
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
<InputNumber
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
value={ka.seconds}
disabled={busy}
onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0);
onChange={(n) => {
setKeepSecs(n);
setDraft({
...draft,
@@ -682,23 +722,17 @@ const DisplayForm: FC<{
/>
<Field label={m.display_max()} htmlFor="display-max">
<Input
{/* 1..=16 is the host's own clamp on write. InputNumber holds the field to it
and unlike the arithmetic this replaces lets it be EMPTY on the way to
a new number instead of snapping to 1 the moment you clear it. */}
<InputNumber
id="display-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(
16,
Math.max(1, Number(e.target.value) || 1),
),
})
}
onChange={(max_displays) => setDraft({ ...draft, max_displays })}
/>
</Field>
@@ -1199,28 +1233,27 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X
</Label>
<Input
{/* A screen left of or above the origin has a NEGATIVE coordinate, and the
arithmetic this replaces made one almost untypable: `Number("-") || 0`
is 0, so the lone minus sign was rewritten to "0" before the digits
could be typed. InputNumber holds "-" as an incomplete draft instead.
`Math.trunc` stays positions are whole pixels. */}
<InputNumber
id={`disp-x-${slot}`}
type="number"
className="w-24"
value={p.x}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "x", Math.trunc(n))}
/>
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y
</Label>
<Input
<InputNumber
id={`disp-y-${slot}`}
type="number"
className="w-24"
value={p.y}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "y", Math.trunc(n))}
/>
</div>
);
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
@@ -11,6 +10,7 @@ import {
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -142,6 +142,13 @@ export const SessionGameCard: FC = () => {
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
{/* Deliberately NOT `InputNumber`, unlike the numeric fields on
the policy card next door. This one writes to the HOST on
blur, and InputNumber commits while you type so its own
blur-time clamp would race the apply below, which still
closes over the pre-clamp value. The host is the authority
here regardless: it clamps to 10..=86400 on write and
answers with what it actually stored. */}
<Input
id="session-grace-seconds"
type="number"
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import {
@@ -10,6 +9,7 @@ import {
import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useState } from "react";
import { ApiError } from "@/api/fetcher";
@@ -11,6 +10,7 @@ import {
} from "@/api/gen/update/update";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
+10 -4
View File
@@ -10,6 +10,7 @@ import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
@@ -313,12 +314,11 @@ export const GameForm: FC<{
"Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
<Checkbox
id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher}
onChange={(e) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
onCheckedChange={(next) =>
setForm((f) => ({ ...f, isLauncher: next === true }))
}
/>
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
@@ -362,6 +362,12 @@ export const GameForm: FC<{
onChange={set("publisher")}
/>
</div>
{/* These two stay `type="number"` over a STRING field rather than becoming
`InputNumber` like the policy card's numbers: both are OPTIONAL metadata
where empty means "don't send it" (see `int()` above), and InputNumber's
contract is `value: number` it cannot express "unset", so adopting it
would invent a year for every entry that hasn't got one. The type here
only asks for a numeric keypad. */}
<div className="grid grid-cols-2 gap-4">
<Field
id="releaseYear"
+14 -14
View File
@@ -1,6 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
@@ -8,7 +7,9 @@ import {
useGetLibrary,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Card, CardContent } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
@@ -29,6 +30,7 @@ export const LibraryGridSection: FC<{
onEntries?: (entries: GameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
@@ -53,7 +55,13 @@ export const LibraryGridSection: FC<{
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
const ok = await confirm({
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
@@ -108,13 +116,9 @@ export const LibraryGrid: FC<{
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.library_launchers_title()}
</p>
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{launchers.map(card)}
</motion.div>
</Stagger>
</div>
)}
{all.length === 0 ? (
@@ -136,13 +140,9 @@ export const LibraryGrid: FC<{
) : (
games.length > 0 && (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{games.map(card)}
</motion.div>
</Stagger>
</div>
)
)}
+32 -19
View File
@@ -2,6 +2,7 @@ import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -10,7 +11,15 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { m } from "@/paraglide/messages";
/**
@@ -152,7 +161,8 @@ const renderable = (node: JsonSchemaNode): boolean => {
if (n.enum) return true;
if (n.type === "boolean" || n.type === "string") return true;
if (n.type === "number" || n.type === "integer") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string")
return true;
if (n.type === "object" && n.properties) {
return Object.values(n.properties).every(renderable);
}
@@ -173,7 +183,8 @@ const ConfigForm: FC<{
// Fall back to the JSON editor when there is no schema, or any field is a shape the generic
// form can't express (a non-enum union, a $ref). Partial rendering would be worse than none:
// a field silently missing from the form is a setting the operator cannot change.
const canRender = props !== undefined && Object.values(props).every(renderable);
const canRender =
props !== undefined && Object.values(props).every(renderable);
if (!canRender) {
return (
@@ -181,8 +192,8 @@ const ConfigForm: FC<{
<p className="text-xs text-muted-foreground">
{m.library_source_settings_json_hint()}
</p>
<textarea
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
<Textarea
className="h-64 font-mono text-xs"
value={raw}
onChange={(e) => onRaw(e.target.value)}
spellCheck={false}
@@ -255,18 +266,21 @@ const Field: FC<{
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<select
id={id}
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<Select
value={String(value ?? node.default ?? node.enum[0])}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
>
{node.enum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<SelectTrigger id={id} size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{node.enum.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
@@ -279,11 +293,10 @@ const Field: FC<{
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
<Checkbox
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
onCheckedChange={(next) => onChange(next === true)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
@@ -300,9 +313,9 @@ const Field: FC<{
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<textarea
<Textarea
id={id}
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
className="h-24 font-mono text-xs"
value={list.join("\n")}
onChange={(e) =>
onChange(
+9 -1
View File
@@ -16,6 +16,7 @@ import {
useInstallPlugin,
useStoreCatalog,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,6 +43,7 @@ export const SourcesSection: FC<{
onFilter: (provider: string | null) => void;
}> = ({ activeFilter, onFilter }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const purge = useDeleteProviderEntries();
@@ -68,7 +70,13 @@ export const SourcesSection: FC<{
const onPurge = async (source: ScannerInfo) => {
const provider = source.provider ?? source.id;
const count = source.entries ?? 0;
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
const ok = await confirm({
title: m.library_provider_purge_confirm({ provider, count }),
description: m.library_provider_purge_body(),
confirmLabel: m.common_remove(),
destructive: true,
});
if (!ok) return;
try {
await purge.mutateAsync({ provider });
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
+10 -2
View File
@@ -11,6 +11,7 @@ import {
useListNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -43,6 +44,7 @@ export interface PairedRow {
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
@@ -65,8 +67,14 @@ export const PairedDevicesSection: FC = () => {
),
];
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
const onUnpair = async (protocol: PairedProtocol, fingerprint: string) => {
const ok = await confirm({
title: m.pairing_native_unpair_confirm(),
description: m.pairing_native_unpair_body(),
confirmLabel: m.action_unpair(),
destructive: true,
});
if (!ok) return;
if (protocol === "native") {
unpairNative.mutate(
{ fingerprint },
+10 -3
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { UserPlus, X } from "lucide-react";
import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model";
@@ -10,7 +9,9 @@ import {
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
@@ -27,6 +28,7 @@ export const PendingDevicesSection: FC = () => {
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const { promptText } = useDialogs();
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
@@ -35,8 +37,13 @@ export const PendingDevicesSection: FC = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
const onApprove = async (id: number, currentName: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
});
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
+10 -2
View File
@@ -9,6 +9,7 @@ import {
useStatsRecordingDelete,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,11 +37,18 @@ export const RecordingsSection: FC<{
onSelect: (id: string | null) => void;
}> = ({ selectedId, onSelect }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const recordings = useStatsRecordingsList();
const del = useStatsRecordingDelete();
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
const onDelete = async (id: string) => {
const ok = await confirm({
title: m.stats_delete_confirm(),
description: m.stats_delete_body(),
confirmLabel: m.stats_delete(),
destructive: true,
});
if (!ok) return;
del.mutate(
{ id },
{
+1 -1
View File
@@ -1,8 +1,8 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
+9 -1
View File
@@ -17,6 +17,7 @@ import {
useSetSource,
useStoreSources,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -49,6 +50,7 @@ const fmtFetched = (secs: number): string =>
* what trusting a third-party catalog means before anything is written to the host.
*/
export const SourcesTab: FC = () => {
const { confirm } = useDialogs();
const sources = useStoreSources();
const refresh = useRefreshCatalog();
const save = useSetSource();
@@ -82,7 +84,13 @@ export const SourcesTab: FC = () => {
};
const onRemove = async (source: StoreSource) => {
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
const ok = await confirm({
title: m.store_source_remove_confirm({ name: source.name }),
description: m.store_source_remove_body(),
confirmLabel: m.store_source_remove(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync(source.name);
} catch (e) {
+9 -4
View File
@@ -12,6 +12,7 @@ import {
useStoreJobs,
useUninstallPlugin,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -31,6 +32,7 @@ type StoreTab = "browse" | "installed" | "sources";
*/
export const SectionStore: FC = () => {
useLocale();
const { confirm } = useDialogs();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
@@ -122,10 +124,13 @@ export const SectionStore: FC = () => {
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
description: m.store_uninstall_body(),
confirmLabel: m.store_uninstall(),
destructive: true,
});
if (!ok) return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
+84
View File
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import { useDialogs } from "@/components/dialogs";
import { Button } from "@/components/ui/button";
import { m } from "@/paraglide/messages";
/**
* The console's confirm/prompt, in place of `window.confirm` / `window.prompt`.
*
* These replaced sixteen native calls. The native ones could not be storied at all a browser
* dialog is chrome, outside the page and outside any screenshot which is part of why they went
* unnoticed for so long in an otherwise fully-branded console.
*
* `open` fires the dialog on mount so the screenshot harness catches it without an interaction.
*/
const Demo = ({
open,
kind,
}: {
open: boolean;
kind: "destructive" | "plain" | "prompt";
}) => {
const { confirm, promptText } = useDialogs();
const [answer, setAnswer] = useState<string>("—");
const ask = async () => {
if (kind === "prompt") {
const name = await promptText({
title: m.display_preset_save_title(),
label: m.display_preset_name(),
defaultValue: "Couch (TV only)",
});
setAnswer(name ?? "cancelled");
return;
}
const ok = await confirm(
kind === "destructive"
? {
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
}
: {
title: m.display_discard_confirm(),
confirmLabel: m.common_discard(),
},
);
setAnswer(String(ok));
};
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once, on mount, for the shot
useEffect(() => {
if (open) void ask();
}, [open]);
return (
<div className="space-y-4">
<Button onClick={ask}>Ask</Button>
<p className="text-sm text-muted-foreground">answered: {answer}</p>
</div>
);
};
// No `DialogsProvider` decorator here on purpose: `.storybook/preview.tsx` mounts it for every
// story, exactly as `__root` does for every route. A second one would work but would quietly render
// a second dialog host.
const meta = {
title: "UI/Dialogs",
component: Demo,
args: { open: true, kind: "destructive" },
} satisfies Meta<typeof Demo>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Anything that destroys or removes: red affirmative, and the consequence spelled out under it. */
export const Destructive: Story = {};
/** A plain choice — no red, because nothing is lost that the operator did not already choose. */
export const Plain: Story = { args: { kind: "plain" } };
/** The prompt: a real labelled field, autofocused, Enter to submit. */
export const Prompt: Story = { args: { kind: "prompt" } };
+95
View File
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
displayPolicy,
displayPresets,
} from "./lib/fixtures";
/**
* The **Virtual displays** policy form the console's largest configuration surface, and until now
* the only page with no story at all. That gap is why a real regression shipped unseen: the preset
* tiles are cards nested INSIDE the page's config card, so their motion parent is that card rather
* than the page's `<Section>`, and a card sets no `delayChildren` every tile landed on the same
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test dropping it would make the story pass for the wrong reason.
*/
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={displayPresets}
customPresets={displayCustomPresets}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={setDraft}
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
);
};
const meta = {
title: "Pages/Displays",
component: Harness,
args: { seed: displayPolicy },
} satisfies Meta<typeof Harness>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A host sitting on a built-in preset — the tiles, and the operator's saved bundles below them. */
export const Default: Story = {};
/** "Custom" reveals every axis by hand: the long form under the tiles. */
export const CustomFields: Story = {
args: { seed: { ...displayPolicy, preset: "custom" } },
};
/** A fresh host has saved no bundles of its own — the custom rail collapses to just "Save as". */
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={args.seed}
setDraft={() => {}}
presets={displayPresets}
customPresets={[]}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={() => {}}
applyAxis={() => {}}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
),
};
+71
View File
@@ -1,6 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
const meta = {
title: "UI/Inputs",
@@ -28,3 +37,65 @@ export const Form: Story = {
</div>
),
};
/**
* The non-text controls, side by side with an Input the comparison that matters, because the
* failure mode these wrappers exist to prevent is a control that looks borrowed from another app.
* A raw `<select>` / `<textarea>` / `<input type="checkbox">` renders in the browser's own chrome
* and ignores the brand tokens entirely; each of the three had survived that way somewhere in the
* console until this story existed to show them together.
*/
export const Controls: Story = {
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ctl-event">Event</Label>
<Select defaultValue="session.started">
<SelectTrigger id="ctl-event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-cmd">Command</Label>
<Input id="ctl-cmd" placeholder="/usr/local/bin/on-stream.sh" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-paths">Extra library folders</Label>
<Textarea
id="ctl-paths"
className="h-24 font-mono text-xs"
placeholder={"/mnt/games\n/mnt/roms"}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox id="ctl-launcher" defaultChecked />
<Label htmlFor="ctl-launcher">This entry opens a launcher</Label>
</div>
</div>
),
};
/** The select with its list open — the surface, the tick, and the hover highlight. */
export const SelectOpen: Story = {
render: () => (
<div className="max-w-sm">
<Select defaultValue="session.started" open>
<SelectTrigger aria-label="Event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client.connected">client.connected</SelectItem>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
),
};
+96
View File
@@ -3,6 +3,9 @@
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { CustomPreset } from "@/api/gen/model/customPreset";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import type { EffectivePolicy } from "@/api/gen/model/effectivePolicy";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { NativeClient } from "@/api/gen/model/nativeClient";
@@ -297,3 +300,96 @@ export const nativeClients: NativeClient[] = [
];
export const pairingIdle: PairingStatus = { pin_pending: false };
/** The six axes a preset expands to — the baseline the built-ins vary from. */
const policyFields = (
over: Partial<EffectivePolicy> = {},
): EffectivePolicy => ({
identity: "per-client",
keep_alive: { mode: "duration", seconds: 300 },
layout: { mode: "auto-row", positions: {} },
max_displays: 4,
mode_conflict: "separate",
topology: "auto",
...over,
});
/**
* The built-in presets as `GET /display/settings` returns them. Summaries are the host's own prose
* (it composes them from the fields), so they are literal strings here rather than i18n messages.
*/
export const displayPresets: {
id: string;
summary: string;
fields: EffectivePolicy;
}[] = [
{
id: "default",
summary:
"A virtual display per client, released 5 minutes after it disconnects.",
fields: policyFields(),
},
{
id: "shared-desktop",
summary:
"Every client sees the same desktop — no extra displays are created.",
fields: policyFields({
identity: "shared",
topology: "primary",
mode_conflict: "join",
}),
},
{
id: "hotdesk",
summary:
"One display at a time; the physical heads go dark while you stream.",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "off" },
}),
},
{
id: "workstation",
summary: "Adds a display beside the monitors already on the desk.",
fields: policyFields({ topology: "extend" }),
},
{
id: "gaming-rig",
summary:
"Pins the display so it survives every disconnect — free it with Release.",
fields: policyFields({
topology: "exclusive",
keep_alive: { mode: "forever" },
}),
},
];
/** Two operator-saved bundles, so the custom-preset rail has something in it. */
export const displayCustomPresets: CustomPreset[] = [
{
id: "cp-couch",
name: "Couch (TV only)",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "forever" },
}),
game_session: "dedicated",
},
{
id: "cp-office",
name: "Office desk",
fields: policyFields({ topology: "extend", identity: "shared" }),
},
];
/** What the host reports as in force — the `default` preset's expansion. */
export const displayEffective: EffectivePolicy = policyFields();
/** The stored policy: a plain built-in pick, which is what most hosts sit on. */
export const displayPolicy: DisplayPolicy = {
preset: "default",
game_session: "auto",
version: 1,
};