forked from unom/punktfunk
Merge pull request 'Gyro: the pipeline was wrong end to end — measured against a real controller, and fixed' (#99) from worktree-gyro-p0-correctness into main
Reviewed-on: unom/punktfunk#99
This commit is contained in:
@@ -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/s² → 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
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,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 (yaw→RY, pitch→RX, roll→RZ) 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/s², 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/s²: 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.
|
||||
|
||||
@@ -153,6 +153,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
|
||||
@@ -524,6 +538,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
|
||||
@@ -573,6 +602,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
|
||||
@@ -722,6 +756,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)
|
||||
|
||||
@@ -283,6 +283,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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 4–8 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,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.
|
||||
@@ -704,6 +712,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,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user