feat(clients): the phone's gyro can speak for a gyro-less pad #88

Merged
enricobuehler merged 2 commits from worktree-gyro-phone-mirror into main 2026-08-07 09:47:09 +00:00
Owner

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).
enricobuehler added 2 commits 2026-08-07 09:39:12 +00:00
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.
feat(client/android): the phone's gyro can speak for a gyro-less pad
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / bun-nix (pull_request) Successful in 30s
android / android (pull_request) Successful in 4m5s
ci / rust-arm64 (pull_request) Successful in 4m1s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust (pull_request) Successful in 10m54s
55aba3e936
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.
enricobuehler merged commit f7ef41b45b into main 2026-08-07 09:47:09 +00:00
enricobuehler deleted branch worktree-gyro-phone-mirror 2026-08-07 09:47:16 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#88