feat(clients): the phone's gyro can speak for a gyro-less pad #88
@@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -126,6 +127,8 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
|
||||
@@ -159,7 +162,7 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
@@ -445,12 +448,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
@@ -598,6 +602,18 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
|
||||
// mirror FROM, which a TV box lacks.
|
||||
if (hasGyroscope) {
|
||||
toggle(
|
||||
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
|
||||
"When the controller has no gyro of its own, send this phone's motion " +
|
||||
"sensors as controller 1's — for clip-on pads without one.",
|
||||
s.gyroOnPhone,
|
||||
) { update(s.copy(gyroOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
|
||||
@@ -158,6 +158,16 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
/**
|
||||
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
|
||||
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
|
||||
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
|
||||
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
|
||||
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
|
||||
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
|
||||
* without a gyroscope (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val gyroOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
@@ -300,6 +310,7 @@ class SettingsStore(context: Context) {
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
@@ -340,6 +351,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
@@ -390,6 +402,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_GYRO_ON_PHONE = "gyro_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
|
||||
@@ -77,6 +77,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -888,6 +889,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
|
||||
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
if (hasGyroscope) {
|
||||
ToggleRow(
|
||||
title = "Gyro from this phone",
|
||||
subtitle = "When the controller has no gyro, send this phone's motion " +
|
||||
"sensors as controller 1's",
|
||||
checked = s.gyroOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
|
||||
@@ -67,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
@@ -455,6 +456,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// "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.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, 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
|
||||
@@ -587,6 +598,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.onHidRaw = null
|
||||
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
|
||||
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) } }
|
||||
|
||||
@@ -123,7 +123,9 @@ class GamepadPaletteTest {
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
@@ -137,7 +139,9 @@ class GamepadPaletteTest {
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
@@ -24,6 +24,7 @@ class GamepadSettingsRowsTest {
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
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
|
||||
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
|
||||
* motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body
|
||||
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
|
||||
* rumble-on-phone mirror with the data flowing the other way.
|
||||
*
|
||||
* On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a
|
||||
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
|
||||
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing —
|
||||
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
|
||||
* at all (motion never creates a host pad; a controller must have arrived first).
|
||||
*
|
||||
* Two properties this class enforces itself:
|
||||
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) —
|
||||
* sensor batching would trade the exact latency gyro aim exists to avoid;
|
||||
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
|
||||
* 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
|
||||
* 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`;
|
||||
* correctable in one place if on-glass says otherwise.
|
||||
*/
|
||||
class DeviceGyro(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter,
|
||||
) : SensorEventListener {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
/** For the live rotation; null on contexts without a display association (then portrait). */
|
||||
private val display: Display? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(WindowManager::class.java)?.defaultDisplay
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
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)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
|
||||
/** Register the listeners; a device without a gyroscope makes this a no-op. */
|
||||
fun start() {
|
||||
val sm = sensorManager ?: return
|
||||
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
thread.start()
|
||||
val h = Handler(thread.looper)
|
||||
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
|
||||
// latency: batching is poison for gyro aim.
|
||||
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
|
||||
* mirror was the live writer. Call BEFORE the router is released / the handle freed —
|
||||
* teardown-ordered like the feedback threads.
|
||||
*/
|
||||
fun stop() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = display?.rotation ?: Surface.ROTATION_0
|
||||
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)
|
||||
}
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
return
|
||||
}
|
||||
wasWriting = true
|
||||
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),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free-fall. */
|
||||
private fun sendZero() {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Whether this device can source motion at all — gates the settings rows (a TV box
|
||||
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
|
||||
fun available(context: Context): Boolean =
|
||||
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
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
* same way under an in-plane rotation) into the controller frame for [rotation]
|
||||
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
|
||||
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
|
||||
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
|
||||
* the device physically turned counter-clockwise, top to the player's LEFT.
|
||||
*/
|
||||
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
|
||||
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
|
||||
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x
|
||||
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
|
||||
else -> floatArrayOf(x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,19 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
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).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && id < 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.Surface
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants
|
||||
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
|
||||
* plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis
|
||||
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DeviceGyroTest {
|
||||
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
|
||||
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
|
||||
|
||||
@Test
|
||||
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
|
||||
|
||||
@Test
|
||||
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
|
||||
|
||||
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
|
||||
* player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */
|
||||
@Test
|
||||
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
|
||||
|
||||
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */
|
||||
@Test
|
||||
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
|
||||
|
||||
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
|
||||
@Test
|
||||
fun handednessPreserved() {
|
||||
for (r in listOf(
|
||||
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
|
||||
)) {
|
||||
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
|
||||
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
|
||||
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ struct GamepadSettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) private var gyroFromDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
@@ -649,6 +650,22 @@ struct GamepadSettingsView: View {
|
||||
value: $rumbleOnDevice),
|
||||
at: at + 1)
|
||||
}
|
||||
// The phone-gyro mirror sits beside the rumble mirror: same clip-on-pad audience,
|
||||
// opposite data direction. Hidden where the device has no motion hardware; engages
|
||||
// in-session only while player 1's controller reports no rotation rate of its own.
|
||||
if DeviceGyro.isAvailable,
|
||||
let anchor = list.firstIndex(where: { $0.id == "deviceRumble" })
|
||||
?? list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceGyro", tab: .controller,
|
||||
icon: "gyroscope",
|
||||
label: "Gyro from this device",
|
||||
detail: "When the controller has no gyro, send this device's motion "
|
||||
+ "sensors as player 1's — for clip-on pads without one of their own.",
|
||||
value: $gyroFromDevice),
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
@@ -712,6 +712,15 @@ extension SettingsView {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: hidden where the
|
||||
// device has no motion hardware, engages only while the player-1 controller
|
||||
// reports no rotation rate of its own.
|
||||
if !inProfileScope, DeviceGyro.isAvailable {
|
||||
described("When the controller has no gyro of its own, sends this device's "
|
||||
+ "motion sensors as player 1's — for clip-on pads without one.") {
|
||||
Toggle("Gyro from this device", isOn: $gyroFromDevice)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
|
||||
@@ -91,6 +91,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) var gyroFromDevice = false
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// The opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): when player 1's forwarded
|
||||
// controller has no rotation sensor of its own, THIS device's IMU speaks for it on the wire's
|
||||
// motion plane — for clip-on and third-party pads that ship without a gyro, where the phone
|
||||
// body is rigidly attached to (or simply is) the thing in the player's hands. The sibling of
|
||||
// `GamepadFeedback`'s rumble-on-device mirror, with the data flowing the other way.
|
||||
//
|
||||
// GamepadCapture owns the engage/stand-down decision (it knows the pad-0 slot and whether its
|
||||
// controller reports a rotation rate); this class only turns CoreMotion on and off and converts
|
||||
// samples. Two invariants it enforces itself:
|
||||
// - one motion writer per pad: samples go out only between `start` and `stop`, and capture
|
||||
// suppresses pad 0's controller-motion forwarding while this runs;
|
||||
// - no stale rotation: `stop` sends a single zero-gyro sample after the last real one, so the
|
||||
// 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).
|
||||
//
|
||||
// Samples are CMDeviceMotion (sensor-fused: bias-corrected rotation rate, gravity split from
|
||||
// user acceleration) at the ~100 Hz CoreMotion ceiling — below a DualSense's 250 Hz, but the
|
||||
// host's motion plane is event-driven, not cadence-locked, so a slower producer just means
|
||||
// fewer samples. Units and axis semantics match `GamepadCapture.forwardMotion` exactly (the
|
||||
// `GamepadWire` constants; accel = gravity + user acceleration — the same convention, so a
|
||||
// future sign/scale correction lands in one place for both sources). The one thing the phone
|
||||
// adds is a frame remap: CoreMotion reports in the device's 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 interface orientation — a phone clipped landscape must yaw
|
||||
// when the player yaws, not roll.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Device-frame → controller-frame axis remap for one interface orientation. CoreMotion's
|
||||
/// frame is fixed to the portrait device (+x right edge, +y top, +z out of the screen); the
|
||||
/// controller frame keeps +z (the screen always faces the player) and rotates x/y so they
|
||||
/// mean "player's right" and "player's up". Derived, like the wire scale constants — pinned
|
||||
/// by `DeviceGyroRemapTests`, correctable in one place if on-glass says otherwise.
|
||||
/// File-scope rather than nested so the sample thread can use it without actor isolation.
|
||||
enum DeviceGyroRemap {
|
||||
case identity
|
||||
/// Upside-down portrait: both in-plane axes flip.
|
||||
case flipped
|
||||
/// Landscape, device top to the player's LEFT (interface `.landscapeRight`):
|
||||
/// player-right = device-bottom, player-up = device-right.
|
||||
case topLeft
|
||||
/// Landscape, device top to the player's RIGHT (interface `.landscapeLeft`).
|
||||
case topRight
|
||||
|
||||
init(_ orientation: UIInterfaceOrientation) {
|
||||
switch orientation {
|
||||
case .portraitUpsideDown: self = .flipped
|
||||
case .landscapeRight: self = .topLeft
|
||||
case .landscapeLeft: self = .topRight
|
||||
default: self = .identity
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
/// same way under an in-plane rotation) into the controller frame.
|
||||
func apply(x: Float, y: Float, z: Float) -> (x: Float, y: Float, z: Float) {
|
||||
switch self {
|
||||
case .identity: return (x, y, z)
|
||||
case .flipped: return (-x, -y, z)
|
||||
case .topLeft: return (-y, x, z)
|
||||
case .topRight: return (y, -x, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class DeviceGyro {
|
||||
/// Whether this device can source motion at all — gates the settings rows (a device
|
||||
/// without an IMU would make the toggle a silent no-op, the rumble mirror's rule).
|
||||
/// One shared probe: Apple recommends a single `CMMotionManager` per app, and the
|
||||
/// settings UI asking per-render must not allocate one each time.
|
||||
public static let isAvailable: Bool = CMMotionManager().isDeviceMotionAvailable
|
||||
|
||||
/// Everything the sample thread touches, behind one lock: the orientation remap (written
|
||||
/// on main when the device rotates), the last converted accel, and whether a real sample
|
||||
/// went out (so `stop` knows it owes the wire a zero). Kept off the actor deliberately —
|
||||
/// `forward` runs on the delivery queue.
|
||||
private final class SampleState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var remap: DeviceGyroRemap = .identity
|
||||
var sentSample = false
|
||||
/// Re-sent with the closing zero-gyro sample so "rotation stopped" doesn't also
|
||||
/// overwrite a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
}
|
||||
|
||||
/// Ship one converted sample (wire pad 0). Must be thread-safe — invoked from the
|
||||
/// delivery queue (`PunktfunkConnection.sendMotion` locks internally).
|
||||
private let send: @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
|
||||
private let motion = CMMotionManager()
|
||||
/// Dedicated serial delivery queue — deliberately NOT main (the controller path's
|
||||
/// main-queue delivery is a known jitter source; the mirror starts clean).
|
||||
private let queue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "punktfunk.device-gyro"
|
||||
q.maxConcurrentOperationCount = 1
|
||||
return q
|
||||
}()
|
||||
|
||||
private let state = SampleState()
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
|
||||
/// Whether the mirror is between `start` and `stop` — read by GamepadCapture to keep the
|
||||
/// controller path off pad 0's motion while this runs.
|
||||
public private(set) var isRunning = false
|
||||
|
||||
public init(
|
||||
send: @escaping @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
self.send = send
|
||||
}
|
||||
|
||||
/// Begin sourcing pad-0 motion from this device. Idempotent.
|
||||
public func start() {
|
||||
guard !isRunning, motion.isDeviceMotionAvailable else { return }
|
||||
isRunning = true
|
||||
updateRemap()
|
||||
// Interface orientation only changes alongside a device-orientation notification, so
|
||||
// this is the one signal needed; re-reading the scene keeps a rotation lock stable.
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.updateRemap() }
|
||||
}
|
||||
// CoreMotion's practical ceiling; requesting faster just clamps.
|
||||
motion.deviceMotionUpdateInterval = 1.0 / 100.0
|
||||
motion.startDeviceMotionUpdates(to: queue) { [state, send] m, _ in
|
||||
guard let m else { return }
|
||||
Self.forward(m, state: state, send: send)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop sourcing and, if anything was sent, park the host pad's rotation at zero. The
|
||||
/// zero rides the same serial queue as the samples, so it is guaranteed last — without
|
||||
/// blocking the caller.
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
isRunning = false
|
||||
motion.stopDeviceMotionUpdates()
|
||||
if let o = orientationObserver {
|
||||
NotificationCenter.default.removeObserver(o)
|
||||
orientationObserver = nil
|
||||
}
|
||||
queue.addOperation { [state, send] in
|
||||
state.lock.lock()
|
||||
let owed = state.sentSample
|
||||
state.sentSample = false
|
||||
let accel = state.lastAccel
|
||||
state.lock.unlock()
|
||||
if owed { send((0, 0, 0), accel) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemap() {
|
||||
let o = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.interfaceOrientation ?? .portrait
|
||||
state.lock.lock()
|
||||
state.remap = DeviceGyroRemap(o)
|
||||
state.lock.unlock()
|
||||
}
|
||||
|
||||
/// Runs on the delivery queue: remap, scale, ship.
|
||||
nonisolated private static func forward(
|
||||
_ m: CMDeviceMotion, state: SampleState,
|
||||
send: (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
state.lock.lock()
|
||||
let r = state.remap
|
||||
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.
|
||||
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))
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
let gyro = (
|
||||
GamepadWire.motionRaw(rot.x, scale: gs),
|
||||
GamepadWire.motionRaw(rot.y, scale: gs),
|
||||
GamepadWire.motionRaw(rot.z, scale: gs)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(acc.x, scale: as_),
|
||||
GamepadWire.motionRaw(acc.y, scale: as_),
|
||||
GamepadWire.motionRaw(acc.z, scale: as_)
|
||||
)
|
||||
state.lock.lock()
|
||||
state.lastAccel = accel
|
||||
state.sentSample = true
|
||||
state.lock.unlock()
|
||||
send(gyro, accel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -67,6 +67,13 @@ public final class GamepadCapture {
|
||||
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).
|
||||
var motionSent = false
|
||||
/// The last accel sent, re-used by the flush zero so "rotation stopped" doesn't
|
||||
/// also replace a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
@@ -153,6 +160,15 @@ public final class GamepadCapture {
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
#if os(iOS)
|
||||
/// Opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): while player 1's forwarded
|
||||
/// controller has no rotation sensor, this device's IMU sources pad 0's motion instead —
|
||||
/// for clip-on pads without a gyro. Session-scoped (the setting is read once here); nil
|
||||
/// when off, unavailable, or forwarding is off (the mirror is wire-only, so with nothing
|
||||
/// to send there is nothing to mirror). Engage/stand-down lives in `updateDeviceGyro`.
|
||||
private let deviceGyro: DeviceGyro?
|
||||
#endif
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
@@ -162,6 +178,17 @@ public final class GamepadCapture {
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
#if os(iOS)
|
||||
if forwarding, DeviceGyro.isAvailable,
|
||||
UserDefaults.standard.bool(forKey: DefaultsKey.gyroFromDevice) {
|
||||
deviceGyro = DeviceGyro { [weak connection] gyro, accel in
|
||||
// Thread-safe (sendMotion locks); pad 0 by the same rule as the rumble mirror.
|
||||
connection?.sendMotion(pad: 0, gyro: gyro, accel: accel)
|
||||
}
|
||||
} else {
|
||||
deviceGyro = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -187,6 +214,9 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated {
|
||||
self?.suspended = true
|
||||
self?.releaseAll()
|
||||
// The mirror pauses with capture (its stop parks the host pad's rotation
|
||||
// at zero — an overlay pull-down must not leave the game spinning).
|
||||
self?.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
@@ -199,11 +229,15 @@ public final class GamepadCapture {
|
||||
for slot in self.slots {
|
||||
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||
}
|
||||
self.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
#if os(iOS)
|
||||
deviceGyro?.stop()
|
||||
#endif
|
||||
closeAllSlots()
|
||||
forwardedSub = nil
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
@@ -224,6 +258,8 @@ public final class GamepadCapture {
|
||||
}
|
||||
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||
updateEscapeChord()
|
||||
// Pad 0 may have changed hands — re-evaluate whether this device's IMU speaks for it.
|
||||
updateDeviceGyro()
|
||||
}
|
||||
|
||||
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||
@@ -561,6 +597,13 @@ public final class GamepadCapture {
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
guard !suspended else { return }
|
||||
#if os(iOS)
|
||||
// While the phone-gyro mirror speaks for pad 0, the controller's own motion —
|
||||
// necessarily rotation-less, that's the engage condition — stays off the wire:
|
||||
// two writers on one pad's motion state would fight, and this accel-only stream
|
||||
// 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
|
||||
@@ -579,18 +622,35 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
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)
|
||||
),
|
||||
accel: (
|
||||
GamepadWire.motionRaw(ax, scale: as_),
|
||||
GamepadWire.motionRaw(ay, scale: as_),
|
||||
GamepadWire.motionRaw(az, scale: as_)
|
||||
))
|
||||
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)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(ax, scale: as_),
|
||||
GamepadWire.motionRaw(ay, scale: as_),
|
||||
GamepadWire.motionRaw(az, scale: as_)
|
||||
)
|
||||
if wire != nil {
|
||||
slot.motionSent = true
|
||||
slot.lastAccel = accel
|
||||
}
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: gyro, accel: accel)
|
||||
}
|
||||
|
||||
/// Engage or stand down the phone-gyro mirror: it speaks for pad 0 exactly while a
|
||||
/// forwarded controller holds that index but can't rotate for itself — no `GCMotion`,
|
||||
/// or a motion object without a rotation rate (gravity-only pads, e.g. an Xbox pad on
|
||||
/// iOS). Re-evaluated on every reconcile and on suspend/resume; `DeviceGyro.stop`
|
||||
/// parks the host pad's rotation at zero, so standing down never strands a spin.
|
||||
private func updateDeviceGyro() {
|
||||
#if os(iOS)
|
||||
guard let gyro = deviceGyro else { return }
|
||||
let pad0 = slots.first { $0.pad == 0 }
|
||||
let wants = !suspended && pad0 != nil && pad0!.controller.motion?.hasRotationRate != true
|
||||
if wants { gyro.start() } else { gyro.stop() }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||
@@ -634,6 +694,14 @@ public final class GamepadCapture {
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
// Motion is host-side STATE, re-emitted until replaced — a nonzero angular velocity
|
||||
// left behind reads as endless rotation (the gyro-sweep latch: Control Center
|
||||
// pull-down froze the last sample for as long as the overlay stayed up). Rest means
|
||||
// zero rotation; the last accel is kept so gravity doesn't become free-fall.
|
||||
if slot.motionSent {
|
||||
slot.motionSent = false
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: (0, 0, 0), accel: slot.lastAccel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||
|
||||
@@ -193,6 +193,14 @@ public enum DefaultsKey {
|
||||
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||
/// has a haptic actuator (no iPad/Mac/TV).
|
||||
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||
/// Use this device's own gyroscope as player 1's motion when the forwarded controller has
|
||||
/// none of its own — for clip-on and third-party pads without an IMU, where the device body
|
||||
/// moves with the player's hands. The rumble mirror's sibling, data flowing the other way.
|
||||
/// Off by default (opt-in); read once per session by `GamepadCapture`, whose `DeviceGyro`
|
||||
/// mirror engages only while pad 0's controller reports no rotation rate (a real gyro pad
|
||||
/// always wins). The toggle is shown only where the device has motion hardware
|
||||
/// (`DeviceGyro.isAvailable`).
|
||||
public static let gyroFromDevice = "punktfunk.gyroFromDevice"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is
|
||||
// derived (like the wire scale constants), so these tests are the contract: if on-glass says an
|
||||
// axis is wrong, fix the enum AND these expectations together.
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DeviceGyroRemapTests: XCTestCase {
|
||||
/// A distinct vector per axis so a swapped or flipped component can't cancel out.
|
||||
private let v: (x: Float, y: Float, z: Float) = (1, 2, 3)
|
||||
|
||||
func testPortraitIsIdentity() {
|
||||
let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3])
|
||||
}
|
||||
|
||||
func testUpsideDownFlipsInPlane() {
|
||||
let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's LEFT: player-right = device-bottom (−y), player-up =
|
||||
/// device-right (+x). z (out of the screen) never changes — the screen faces the player.
|
||||
func testTopLeftLandscape() {
|
||||
let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's RIGHT: player-right = device-top (+y), player-up =
|
||||
/// device-left (−x).
|
||||
func testTopRightLandscape() {
|
||||
let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3])
|
||||
}
|
||||
|
||||
/// Interface orientation → remap: `.landscapeRight` means the Home edge is on the
|
||||
/// player's right, i.e. the device top points LEFT (and vice versa).
|
||||
func testOrientationMapping() {
|
||||
XCTAssertEqual(DeviceGyroRemap(.portrait), .identity)
|
||||
XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight)
|
||||
XCTAssertEqual(DeviceGyroRemap(.unknown), .identity)
|
||||
}
|
||||
|
||||
/// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping.
|
||||
func testHandednessPreserved() {
|
||||
for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] {
|
||||
let x = remap.apply(x: 1, y: 0, z: 0)
|
||||
let y = remap.apply(x: 0, y: 1, z: 0)
|
||||
// Cross product of the two mapped in-plane basis vectors.
|
||||
let cross = (
|
||||
x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x
|
||||
)
|
||||
XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user