Opt-in phone-gyro mirror on the Apple and Android clients — the "Rumble on this phone" mirror's sibling, with the data flowing the other way. Gyro gamers with a pad that has no IMU (most third-party and clip-on controllers, every Xbox pad) can turn on Gyro from this device / phone and have the phone's own motion sensors drive controller 1's wire motion, which the host already injects into its virtual DualSense-class pads.
How it engages (both platforms: only when the pad can't do it itself)
Apple (DeviceGyro + GamepadCapture): engages while pad 0's controller reports no rotation rate (no GCMotion, or the gravity-only motion an Xbox pad exposes); the controller's accel-only stream is suppressed meanwhile so there's exactly one motion writer per pad. Re-evaluated on reconcile, suspend/resume, and stop. CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a dedicated queue (not main).
Android (DeviceGyro + two GamepadRouter queries): stands down per sample whenever a capture link (USB DualSense / SC2 — pads with a real IMU) holds wire pad 0, and sends nothing while pad 0 has no slot. TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a dedicated HandlerThread, batching disabled.
Frame + units: samples are rotated from the device's portrait sensor frame into the controller frame by interface orientation / display rotation (a phone clipped landscape yaws when the player yaws, not rolls), then converted with the shared wire contract (20 LSB/°·s, 10000 LSB/g). The remap matrices are pinned by DeviceGyroRemapTests (Swift) and DeviceGyroTest (Kotlin).
Settings: off by default; rows appear beside the rumble mirror in all four surfaces (Apple touch + gamepad UI, Android touch + TV UI), hidden where the device has no gyroscope — the rumble mirror's rule.
Stale-rotation discipline (from today's gyro sweep): the host holds motion as state and re-emits it, so a nonzero angular velocity left behind reads as endless rotation. Both mirrors send one zero-gyro sample on every stand-down edge, and the Apple client's flush now also parks controller-path motion at zero (keeping the last accel so gravity doesn't become free-fall) — this fixes the sweep's Control-Center-latch repro independently of the new feature.
Verified: full Apple-client Swift build green on arm64-apple-ios17.0 (the triple that actually compiles the iOS-gated code); Android :kit + :app unit tests green including the new pin tests (6/6), JNI-import check clean at the API-28 floor on all 3 ABIs.
Known limits (documented in code): the remap matrices and the mirror's axis conventions are derived, not yet live-verified on glass — same status the existing capture paths declare for their sign/scale; each is a one-line change in one place if a session says otherwise. tvOS is untouched (no gyro, rows hidden).
Opt-in phone-gyro mirror on the Apple and Android clients — the "Rumble on this phone" mirror's sibling, with the data flowing the other way. Gyro gamers with a pad that has no IMU (most third-party and clip-on controllers, every Xbox pad) can turn on **Gyro from this device / phone** and have the phone's own motion sensors drive controller 1's wire motion, which the host already injects into its virtual DualSense-class pads.
**How it engages (both platforms: only when the pad can't do it itself)**
- **Apple** (`DeviceGyro` + `GamepadCapture`): engages while pad 0's controller reports no rotation rate (no `GCMotion`, or the gravity-only motion an Xbox pad exposes); the controller's accel-only stream is suppressed meanwhile so there's exactly one motion writer per pad. Re-evaluated on reconcile, suspend/resume, and stop. CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a dedicated queue (not main).
- **Android** (`DeviceGyro` + two `GamepadRouter` queries): stands down per sample whenever a capture link (USB DualSense / SC2 — pads with a real IMU) holds wire pad 0, and sends nothing while pad 0 has no slot. TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a dedicated HandlerThread, batching disabled.
**Frame + units:** samples are rotated from the device's portrait sensor frame into the controller frame by interface orientation / display rotation (a phone clipped landscape yaws when the player yaws, not rolls), then converted with the shared wire contract (20 LSB/°·s, 10000 LSB/g). The remap matrices are pinned by `DeviceGyroRemapTests` (Swift) and `DeviceGyroTest` (Kotlin).
**Settings:** off by default; rows appear beside the rumble mirror in all four surfaces (Apple touch + gamepad UI, Android touch + TV UI), hidden where the device has no gyroscope — the rumble mirror's rule.
**Stale-rotation discipline (from today's gyro sweep):** the host holds motion as *state* and re-emits it, so a nonzero angular velocity left behind reads as endless rotation. Both mirrors send one zero-gyro sample on every stand-down edge, and the Apple client's `flush` now also parks controller-path motion at zero (keeping the last accel so gravity doesn't become free-fall) — this fixes the sweep's Control-Center-latch repro independently of the new feature.
**Verified:** full Apple-client Swift build green on `arm64-apple-ios17.0` (the triple that actually compiles the iOS-gated code); Android `:kit` + `:app` unit tests green including the new pin tests (6/6), JNI-import check clean at the API-28 floor on all 3 ABIs.
**Known limits (documented in code):** the remap matrices and the mirror's axis conventions are derived, not yet live-verified on glass — same status the existing capture paths declare for their sign/scale; each is a one-line change in one place if a session says otherwise. tvOS is untouched (no gyro, rows hidden).
Opt-in "Gyro from this device" (DefaultsKey.gyroFromDevice, off by default,
iOS only): while player 1's forwarded controller reports no rotation rate of
its own — no GCMotion, or the gravity-only motion an Xbox pad exposes — this
device's IMU sources pad 0's wire motion instead. The rumble-on-device
mirror's sibling, data flowing the other way: same session-scoped
UserDefaults read, same hardware-gated settings rows, same pad-0 rule.
DeviceGyro wraps CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a
dedicated serial queue (not main — the controller path's main-queue delivery
is a known jitter source), converts with the shared GamepadWire constants,
and rotates each sample from the device's portrait frame into the controller
frame by interface orientation, so a phone clipped landscape yaws when the
player yaws instead of rolling. The remap matrix is derived and pinned by
DeviceGyroRemapTests.
GamepadCapture owns engage/stand-down (reconcile, suspend/resume, stop), and
suppresses pad 0's controller-motion forwarding while the mirror runs — two
writers on one pad's motion state would fight, and the accel-only stream
would stomp the mirror's gyro with zeros.
Also fixes the stale-motion latch from the gyro sweep on the controller
path: flush now parks motion at zero (keeping the last accel, so gravity
doesn't become free-fall), and the mirror's stop sends the same closing
zero. The host holds motion as state and re-emits it — a nonzero angular
velocity left behind read as endless rotation for as long as an overlay
(Control Center pull-down) kept the app inactive.
Opt-in "Gyro from this phone" (gyro_on_phone, off by default): this
device's IMU sources wire pad 0's motion while that pad is a controller
with no motion source of its own. On Android that gate is exact — the only
pads that forward motion are the capture links (USB DualSense / SC2,
claimed as ExternalPads), so the mirror stands down per sample whenever
GamepadRouter.padHasOwnMotion(0) says a capture link holds the index, and
sends nothing while pad 0 has no slot at all (motion never creates a host
pad). "Rumble on this phone"'s sibling, data flowing the other way: same
read-once-at-attach settings plumbing, same hardware-gated rows in the
touch and controller settings (a TV box has no gyroscope to mirror from).
DeviceGyro registers TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a
dedicated HandlerThread with batching disabled (maxReportLatencyUs = 0 —
batching is poison for gyro aim), converts with the wire contract shared
with pf-client-core (rad/s → 20 LSB/°·s, m/s² → g → 10000 LSB/g; Android's
accelerometer already reads specific force, the DualSense report's own
convention), and rotates each sample from the natural-portrait sensor
frame into the controller frame by display rotation — a phone clipped
landscape yaws when the player yaws instead of rolling. The remap matrix
and unit constants are pinned by DeviceGyroTest.
A stand-down edge (capture link claims pad 0, or session teardown) sends
one zero-gyro sample so the host's virtual pad never keeps integrating an
angular velocity this device stopped producing — the gyro sweep's
stale-rotation latch, avoided by construction here.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Opt-in phone-gyro mirror on the Apple and Android clients — the "Rumble on this phone" mirror's sibling, with the data flowing the other way. Gyro gamers with a pad that has no IMU (most third-party and clip-on controllers, every Xbox pad) can turn on Gyro from this device / phone and have the phone's own motion sensors drive controller 1's wire motion, which the host already injects into its virtual DualSense-class pads.
How it engages (both platforms: only when the pad can't do it itself)
DeviceGyro+GamepadCapture): engages while pad 0's controller reports no rotation rate (noGCMotion, or the gravity-only motion an Xbox pad exposes); the controller's accel-only stream is suppressed meanwhile so there's exactly one motion writer per pad. Re-evaluated on reconcile, suspend/resume, and stop. CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a dedicated queue (not main).DeviceGyro+ twoGamepadRouterqueries): stands down per sample whenever a capture link (USB DualSense / SC2 — pads with a real IMU) holds wire pad 0, and sends nothing while pad 0 has no slot. TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a dedicated HandlerThread, batching disabled.Frame + units: samples are rotated from the device's portrait sensor frame into the controller frame by interface orientation / display rotation (a phone clipped landscape yaws when the player yaws, not rolls), then converted with the shared wire contract (20 LSB/°·s, 10000 LSB/g). The remap matrices are pinned by
DeviceGyroRemapTests(Swift) andDeviceGyroTest(Kotlin).Settings: off by default; rows appear beside the rumble mirror in all four surfaces (Apple touch + gamepad UI, Android touch + TV UI), hidden where the device has no gyroscope — the rumble mirror's rule.
Stale-rotation discipline (from today's gyro sweep): the host holds motion as state and re-emits it, so a nonzero angular velocity left behind reads as endless rotation. Both mirrors send one zero-gyro sample on every stand-down edge, and the Apple client's
flushnow also parks controller-path motion at zero (keeping the last accel so gravity doesn't become free-fall) — this fixes the sweep's Control-Center-latch repro independently of the new feature.Verified: full Apple-client Swift build green on
arm64-apple-ios17.0(the triple that actually compiles the iOS-gated code); Android:kit+:appunit tests green including the new pin tests (6/6), JNI-import check clean at the API-28 floor on all 3 ABIs.Known limits (documented in code): the remap matrices and the mirror's axis conventions are derived, not yet live-verified on glass — same status the existing capture paths declare for their sign/scale; each is a one-line change in one place if a session says otherwise. tvOS is untouched (no gyro, rows hidden).