diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index 6274636d..3a091fba 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -114,10 +114,19 @@ object DsDevice { companion object { /** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */ private const val RAW_ACCEL_LSB_PER_G = 8192L - /** `punktfunk_core::input::gamepad::MOTION_GYRO_LSB_PER_DEG_S`. */ - private const val WIRE_GYRO_LSB_PER_DEG_S = 20L + /** + * 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 const val ACCEL_NUMER = 2 * 10000L + 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 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 7e06b0d2..158cb2af 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -71,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 @@ -378,6 +389,12 @@ class GamepadRouter( */ 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() + } } /** @@ -390,6 +407,11 @@ class GamepadRouter( 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], @@ -540,7 +562,14 @@ 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.