Merge branch 'worktree-agent-a6f74e8ea7824fb56' into worktree-gyro-p0-correctness

This commit is contained in:
2026-08-07 20:14:41 +02:00
7 changed files with 485 additions and 43 deletions
@@ -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
@@ -475,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
@@ -615,6 +636,9 @@ 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) } }
@@ -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
@@ -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,
@@ -85,12 +87,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.
@@ -335,14 +358,48 @@ 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)
}
/**
* 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
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
@@ -485,6 +542,9 @@ class GamepadRouter(
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
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
}
@@ -0,0 +1,248 @@
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 NOT VERIFIED ON THIS PATH.** The wire is a unit passthrough into a
* virtual DualSense report, and that report's frame was MEASURED on 2026-08-07 over raw
* HID: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward, toward the player
* (roll), right-handed. Android documents its sensor frame for a handheld device as +x
* right, +y up, +z out of the face the same frame once "the face" is read as the one the
* player looks at, which is what the platform means by a controller's own sensor. So
* straight through is the mapping the documentation implies, and it is what this does.
* What nobody has DONE is put a Bluetooth DualSense in front of the platform sensor
* framework and compare: those numbers come through a HID driver and InputFlinger's sensor
* mapper, either of which could permute or negate without saying so.
*
* The measurement that settles it is the DualSense one repeated on this path pad flat
* and still, then three labelled rotations:
* - at rest, gravity must land as +1 g on ACCEL slot 1 (not 0, not 2);
* - yaw clockwise seen from above GYRO slot 1 negative;
* - pitch the far edge down slot 0 negative;
* - roll right-side down slot 2 negative.
* Any disagreement is a remap, and it belongs HERE with its own expectations in
* `PadSensorsTest` not spread across callers, and not guessed at in advance.
*/
fun gyroToWire(values: FloatArray, out: IntArray) {
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
}
/**
* One accelerometer sample (Android: m/, specific force) the wire's three signed-16
* components, in place. Same unverified 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]).
*/
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)
}
}
@@ -0,0 +1,92 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
* JVM the two `*ToWire` functions take plain float arrays and touch no Android class.
*
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
* not drift. The axis mapping is straight through and NOT yet verified against hardware see
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
* to make a future remap a deliberate, visible edit rather than a quiet one.
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class PadSensorsTest {
private fun gyro(x: Float, y: Float, z: Float) =
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
private fun accel(x: Float, y: Float, z: Float) =
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
@Test
fun gyroScaleFromRadiansPerSecond() {
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
assertEquals(0, gyro(0f, 0f, 0f)[0])
}
/** 10000 LSB/g from Android's m/: standard gravity is exactly 1 g. Android reports specific
* force, so a pad at rest reads +1 g on the axis pointing up no sign flip anywhere. */
@Test
fun accelScaleFromMetresPerSecondSquared() {
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
assertEquals(0, accel(0f, 0f, 0f)[1])
}
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
* DualSense 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
* not the [0,0,0] that means free fall. */
@Test
fun restingPadIsTheHostNeutral() {
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
}
/**
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
* planes, with no permutation and no negation. UNVERIFIED against hardware if a Bluetooth
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
*/
@Test
fun straightThroughFrame() {
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
assertArrayEquals(
intArrayOf(10_000, 20_000, -30_000),
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
)
}
/** Both planes clamp to signed 16 bits rather than wrapping a flick past 1638 °/s or a knock
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
@Test
fun clampsToSigned16() {
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
}
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
* is a gyro that drifts. */
@Test
fun roundsToNearestNotTowardZero() {
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
}
/** A sensor that hands back fewer than three components (or none the framework reuses one
* array across types) contributes zero rather than throwing on the sensor thread. */
@Test
fun shortSampleIsZeroFilled() {
val out = IntArray(3) { 7 }
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
assertArrayEquals(intArrayOf(3600, 0, 0), out)
}
}