Gyro: the pipeline was wrong end to end — measured against a real controller, and fixed #99
Open
enricobuehler
wants to merge 22 commits from
worktree-gyro-p0-correctness into main
pull from: worktree-gyro-p0-correctness
merge into: :main
:main
:worktree-gyro-p0-correctness
:worktree-release-0250
:worktree-native-decode-m0
:worktree-july-rpm-and-cursor
:worktree-presenter-cadence-wp1
:worktree-decky-brand-name
:worktree-win-amd-decoder-order
:worktree-stall-ride-through
:fix/pad-audio-wasapi-module-path
:feat/android-pad-audio
:worktree-adl-emul-probe
:worktree-haptics-m12-dry
:worktree-haptics-m9-richfb
:worktree-haptics-m11-settings
:worktree-apple-store-screenshots
:worktree-decky-slim-rework
:worktree-appstore-copy
:worktree-haptics-m10-wire
:worktree-haptics-m8-proto
:worktree-haptics-m7-windows
:worktree-haptics-m6-presenter
:worktree-wire-mtu-resilience
:worktree-haptics-m5-android
:worktree-haptics-m4-apple
:worktree-haptics-m3-rumble-engine
:worktree-android-presenter-023-triage
:worktree-vdisplay-resume-recovery
:worktree-kwin-vout-demirror
:fix/web-console-sweep
:fix/windows-web-console-lifecycle
:chore/windows-rerender-semantics
:fix/pf-encode-phase8
:fix/pf-encode-phase7
:feat/cursor-channel
:feat/desktop-mouse-mode
:feat/plugin-kit
:fix/network-split-standing-latency
:fix/windows-msix-arm64-and-manifest
:fix/encode-medium-tier
:fix/encode-rfi-taint-and-oob
:fix/encode-init-leak-and-twin-drift
:fix/encode-teardown-uaf
:feat/pyrowave-windows
:fix/ubuntu-2404-host-deb
:fix/ios-archive-provisioning
:merge/first-frame-latency
:feat/shared-clipboard-v2
:feat/apple-live-activities-widgets
:debug/touch-finger-logging
:feat/shared-clipboard
:midstream-resize
Reference in New Issue
Block a user
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.
Twenty-three commits closing out the gyro program's correctness work (design/gyro-program.md — G1–G6, G8, G10, G13–G17). Motion was broken at almost every layer: the host decoded DualShock 4 gyro 40× too fast, a pad that stopped turning kept turning forever, the Windows driver halved the rate and served torn reports, the Apple client sent acceleration upside down and in the wrong axis frame, Android under-reported by ~18% over USB and sent nothing at all over Bluetooth, and every virtual pad at rest claimed it was in free fall.
Most of it was invisible: gyro that silently does nothing is indistinguishable from a broken sensor, so none of this arrives as a bug report.
The measurement that unblocked the rest
Two clients disagreed about the motion axis frame — Apple put gravity on Z, Android on Y — and nothing in the codebase could settle it, because both are conventions applied on top of the pad. The notes had this down as needing bare-metal Linux, which is why it sat blocked while the test boxes were down.
That premise was wrong. The wire is a unit passthrough: the host writes
gyro/accelstraight into the virtual pad's report bytes 16../22.., in order, no permutation. So the frame the wire is defined in is the pad's own HID report frame — readable over raw HID on any machine with a USB cable.One DualSense, read twice — raw HID and GameController:
(Right, Up, Backward)(Right, Forward, Up)Over-determined: gyro and accelerometer independently agree on the same triad. The wire's documented naming was right all along (
gyro[0]=pitch, [1]=yaw, [2]=roll), and Android's USB path needed no remap — its bad readings were purely scale.Verified on hardware, not derived
Every client path was then measured end to end into
hid-playstationon a real host.GamepadCaptureas iOS)y=+0.983, |a|≈1.000DsCapture)y=+0.992, |a|≈1.010 — was 0.811PadSensors, new in G10)y=+0.991Two independent confirmations fell out of this. The Android calibration read logged
gyro 16/16/16 LSB/°·s, accel 8177/8188/8193—16proves the pad's real blob was read rather than the nominal fallback (which reads back as exactly20), and those accel numbers are byte-identical to the Mac's independenthidapiread of the same pad, from a completely different code path. And the earlier "does the gyro share the accel frame" worry — which had rested on a 1.22× margin — is retired by using gravity as an independent witness for the gyro's sign.What's in it
Host — DS4's blob resolved to 0.5 LSB/°·s against a contract of 20, in two byte-identical copies; fixed in both, with a test that parses the UMDF driver's own source via
include_str!. Real sensor clocks. An idle watchdog. The Windows driver's 8 ms timer → 2 ms plus a seqlock on the previously-torn input slot. And a pad at rest no longer reports[0,0,0], which is not "no data" but free fall — worst for a pad with no gyro at all, which sits on that neutral all session.Apple — the sign fix and the frame fix. The 4 ms motion floor was a drop, not a pace: a rate that gets integrated loses rotation permanently, and at a floor set at the pad's jittery rate the loss is steady and one-signed, accumulating as aim drifting short. Capture also attached to any
GCMotion, so an Xbox pad streamed permanently-zero rotation as authoritative gyro.Android — the calibration feature report is read once at claim and the pad's own numbers applied (a nominal constant provably cannot fix gyro). Plus G10: Bluetooth controllers had no motion path at all;
InputDevice.getSensorManager()now feeds them, coordinated so USB claim wins, the phone mirror stands down, and rotation parks at zero on stop.Reach + docs — clients consume the resolved-pad echo and say when a gyro can't reach the session;
5c4969fdfixes a regression in the first cut of that (it read the session echo, but the host builds each pad from its own arrival). The support matrix no longer promises motion an Xbox-class backend cannot carry.Merges, and the errors they caught
main moved ~60 commits during this work, including #88 (phone-gyro mirror), and G10 arrived on its own branch off main. Both merges surfaced real defects — the argument for merging rather than rebasing past them:
DeviceGyropromised a sign/scale correction "lands in one place for both sources"; onlyGamepadCapturehad been corrected, leaving the mirror sending acceleration un-negated (1eab4b66).7a4cdac5), plus the last duplicate scale constant in that module.1eab4b66also applied the controller path's frame conversion to the mirror, which did not need it. Two different frames are both called "the controller frame" —GCMotion's is(Right, Forward, Up), but the mirror's remap already targets the wire's. A still phone would have claimed −1 g on the roll axis. Caught by measuring the Android twin, reverted inee61e8c9; the negation half was right and stays.Verification
Every behavioural change is mutation-checked in both directions — reverting each fix fails a named assertion, recorded in its commit. Frame constants are pinned against the measurements, including the tilt term that discriminates against the five other permutations that also land gravity on the right slot, plus isometry and handedness properties (a wrong sign count is a reflection: plausible per-axis, inverts every rotation).
Gates against the merged tree: Linux CI image
clippy --locked --all-targets -D warnings+ suites; Apple 215 tests + iOS-triple typecheck; Android kit 75 / app 67, counts read from the JUnit XML.Owed
DeviceGyroRemap's four orientation matrices (both clients) remain derived. Tonight's runs used a controller's own sensors, not the phone mirror, so they say nothing about these. Settling them needs a gyro-less pad on wire index 0 and a phone turned through all four orientations.Working G14/G18 turned up two sweep findings that do not survive contact with the code. Neither is implemented; one is now guarded. The 2026-08-07 sweep read the Triton (Steam Controller 2) usbip endpoint's `bInterval: 1` as 125 µs — an 8 kHz duplicate storm — and the plan's G14 says to raise it to 4 "like the Deck". That reading assumes a high-speed device, where bInterval is the 2^(n-1) × 125 µs exponent. Both Triton devices declare `UsbSpeed::Full`, and on a full-speed device the field is a plain frame count in milliseconds: 1 means 1 ms, which is the 1 kHz the existing comment claims. Raising it to 4 would mean 4 ms — a 4× cut to the motion rate a passed-through SC2 delivers, in the name of fixing a problem it doesn't have. The endpoint now carries the reasoning so the next reader doesn't repeat it. G18's first bullet ("bound/rate-cap the host's rich-input channel; motion is unbounded") is stale rather than wrong — it was true of the tree the sweep read. Current main already routes rich input, motion included, through a 1024-deep `sync_channel` whose `offer()` helper `try_send`s and drops on full, ending the loop only on Disconnected. That is the same bounded-queue pattern the mic plane adopted for security-review S6. Nothing owed. G14's remaining bullet — DS/Deck neutral accel should read 1 g on the up axis instead of 0 g free-fall — is deliberately NOT done here. Which axis is up is precisely what G16's on-glass session measures: `switch_proto` documents the wire as z-up and its neutral ships +Z, but the Deck's kernel negates Z/RZ, so guessing would leave one backend confidently disagreeing with another. A wrong constant is worse than the current obviously-unset 0. Gate: fmt, build, clippy --all-targets -D warnings, and the test suites — green.G16, first result. A DualSense paired to an iPhone, streaming to a Linux host, lying flat and face up: hid-playstation decoded z = −0.99 g where a DualSense owes +1.00. Vector magnitude was 1.006 g, so the scale was already correct — this is purely direction, and it was wrong for every accelerometer sample the Apple client has ever sent. The cause is a convention mismatch, not a sign typo. Apple reports acceleration as the gravity VECTOR, which points down: a device face-up on a table reads z = −1. An accelerometer physically measures proper acceleration, and at rest that is the +1 g normal force pushing UP — which is what a DualSense's report, and therefore our wire, carries. The two are exact negatives. Both branches were affected, because `m.acceleration` follows the same Apple convention as the gravity/userAcceleration split, so reading the "raw vector" was not an escape from it. `rotationRate` is a true angular rate and needs no flip. The same session confirmed that independently: rotating the pad clockwise seen from above produced a negative yaw, which is correct under the right-hand rule about an up-pointing Z. That asymmetry — accel wrong, gyro right — is itself evidence for this diagnosis rather than a blanket frame error, and it is why the fix is three negations at one site instead of a remap. The sweep predicted this ("Apple accel plausibly INVERTED — CoreMotion gravity -1 g vs DS +1 g up at rest") but could not confirm it without hardware. It is now measured, and the mechanism is confirmed in the code rather than inferred from the number. Method, for whoever repeats it: the readout is python-evdev on the host reading the virtual pad's own motion node, dividing by the axis `resolution` the kernel publishes, so it prints deg/s and g. That is downstream of the calibration blob — the same layer a game reads — which is what makes a sign error visible to a human at all. Two things this does NOT establish. The host was a KVM guest, so the DualSense could not be attached natively for a side-by-side reference reading; the test stands on the DualSense convention being a fixed property of the hardware, which is decisive for the at-rest sign but weaker for the gyro axis ORDER. And the fix itself is unverified on glass: confirming it needs a rebuilt client on the device, so someone should re-run the same at-rest reading and see +1.00. Gate: `swiftc -parse` clean. A full typecheck needs the gitignored PunktfunkCore.xcframework assembled first and has not been run.G17's motion half. The docs described what the CLIENT sends and stopped there, which made a promise the host does not always keep. The support matrix said a desktop client forwards motion from any pad SDL exposes a gyro on "and the host injects it into the matching virtual pad". The first clause is true; the second is only true when the virtual pad has a motion plane. The X-Box 360 and One backends do not — no gyro in their HID contract — so the host parses every sample and discards it. That is where *Automatic* lands anything it does not recognise as Sony or Valve, an 8BitDo with a perfectly good gyro included, and where a Switch Pro lands on a Windows host with no `hid-nintendo` backend to fold it into. A reader following the old text would conclude their gyro was broken. The failure has no other symptom: motion just does nothing. So both pages now say what to do about it — pick a DualSense-class type — and the client-settings page says it where the choice is actually made, next to the degrade paragraph that explains why a session ends up on an X-Box pad in the first place. The Deck's Steam-Input requirement moves out of Decky's settings blurb, which is the one place a Deck user streaming FROM the Deck would never look. With Steam Input on, Steam hands the app its own virtual X-Box pad, so no controller-type choice can help: there is no gyro on the pad the client can see. The picker help text now mentions motion on GTK and Android, which is where it was missing — Windows already said it and Apple says it in its own words. One sentence, the same sentence, so the four clients answer the question the same way. This is the doc side of the on-screen notice that shipped earlier in this branch. The two exist for the same reason and now agree: the client says it when it detects the case, the docs say it when someone goes looking. Not covered: the preset COUNTS in note 1 ("Android and the console home offer six … Windows and Apple offer five") are still unverified against the four pickers, and the Apple picker's missing Steam Deck entry is a code gap rather than a doc one. Both are noted in the plan and left for their own change rather than guessed at here. Gate: Linux CI image fmt + `clippy --locked --all-targets -D warnings` on punktfunk-client-linux (the GTK string is compiled) plus the core crates and their tests; Android `:app:compileDebugKotlin` + `:app:testDebugUnitTest`. Green.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.