Gyro: the pipeline was wrong end to end — measured against a real controller, and fixed #99
Merged
enricobuehler
merged 22 commits from 2026-08-07 19:51:19 +00:00
worktree-gyro-p0-correctness into main
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ee61e8c9ba |
fix(clients/pads): the phone mirror never needed the controller path's frame change
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 2m12s
windows-drivers / probe-and-proto (pull_request) Successful in 21s
android / android (pull_request) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m32s
Reverts half of |
||
|
|
7a4cdac5b7 |
fix(client/android): a Bluetooth pad's gyro obeys the same reachability gate as the rest
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m34s
windows-drivers / driver-build (pull_request) Successful in 1m34s
android / android (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m15s
windows-drivers / probe-and-proto (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 6m34s
apple / swift (pull_request) Failing after 11m43s
apple / screenshots (pull_request) Skipped
Follow-up to the G10 merge. The new sensor path was written against main, which does not carry this branch's G8 work, so it forwarded motion unconditionally — the one thing G8 exists to stop. `deviceMotion` checked `forwarding` and nothing else. A Bluetooth DualSense in a session that resolved to an X-Box backend would stream ~200 Hz of samples the host parses and discards, for the whole session, exactly as the USB capture path did before G8. Not a regression against shipped behaviour — the path is new — but it would have shipped the defect back into a client that had just been taught not to have it. `Slot` now carries `motionReaches`, asked once at open off the kind that pad DECLARED, in the same shape `ExternalPad` already used. Per pad, not per session: under Automatic the handshake carries the active pad's kind, so a couch with an X-Box pad on slot 0 and a DualSense on slot 1 must not have slot 1's working gyro suppressed by slot 0's answer. The notice moved to where the truth is known. `openSlot` knows only what kind a pad declared, not whether it physically has a gyro — that is discovered later, when `PadSensors` finds a gyroscope and calls `setDeviceHasSensorMotion`. Raising it there is the only placement that both tells a player whose gyro is being dropped and stays silent for the pads that never had one. Also unified the last duplicate scale in the module. G10 hoisted the wire units into `Gamepad` and pointed `DeviceGyro` at them, but `DsDevice` kept its own `20L` / `10000L` — and `Gamepad`'s new comment claims every sender goes through one place, which was not yet true. Two copies of a unit constant in one module is precisely the defect this program opened with (a DualShock 4 blob 40× hot because a second copy had drifted), so the claim and the code now agree. `val` rather than `const val` only because widening to Long is not a constant expression; Long is deliberate, since the calibration arithmetic overflows an Int before it divides. Proven non-vacuous rather than assumed: changing `Gamepad.MOTION_GYRO_LSB_PER_DEG_S` from 20 to 16 now fails four named cases across three classes — `DsDeviceTest.calibrationRescalesRawCountsOntoTheWireUnits`, `.theHostsOwnBlobIsAPassthrough`, `.parseStateAppliesTheCalibration` and `DeviceGyroTest.wireUnitConstants`. Before this change `DsDevice` would not have noticed. The gate itself has no test, for the reason the surrounding code already documents: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric in this module, and a mock would test the mock. It is argued at the call sites instead. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`, `:app:testDebugUnitTest` — kit 75 / app 67, 0 failures, counts read out of the JUnit XML. The merge reconciles: 62 on this branch, plus 6 from main's DeviceGyroTest, plus G10's 7. |
||
|
|
e81ab1ff2b | Merge branch 'worktree-agent-a6f74e8ea7824fb56' into worktree-gyro-p0-correctness | ||
|
|
8f1081719f |
feat(client/android): a Bluetooth controller's gyro stops going nowhere
Android had two motion sources and both of them are USB claims. DsCapture takes a Sony pad's HID interface away from the kernel; Sc2Capture does the same for a Steam Controller 2. Everything else — a DualSense, a DualShock 4, a Switch Pro, an 8BitDo, paired over Bluetooth — arrives as an ordinary InputDevice. Its buttons worked, its sticks worked, and its gyro was dead, silently, with no log line and nothing in the UI to suggest the pad had a sensor at all. That is not one controller, it is the whole class of controllers people actually pair to a phone. The platform has had the answer since Android 12: InputDevice.getSensorManager hands back a SensorManager scoped to that one controller, carrying its TYPE_GYROSCOPE and TYPE_ACCELEROMETER. PadSensors registers a listener per forwarded pad that has a gyroscope and sends the samples on that pad's wire index. Below API 31 it registers nothing and the pads behave exactly as they did. It is built on DeviceGyro's shape, because the phone mirror had already paid for these lessons. One dedicated HandlerThread, never the main one. Batching off (maxReportLatencyUs = 0) — batching would trade away precisely the latency gyro aim exists to avoid. 200 Hz requested, which is also the ceiling the framework grants an app without HIGH_SAMPLING_RATE_SENSORS, so asking for more would only be capped. And a feed that lets go of a pad still alive parks its rotation at zero first: the host holds motion as state and re-emits it in every virtual-pad report, so an angular velocity left behind is a pad that rotates forever. Two writers on one pad's motion is the failure this program has spent the day unpicking, so the coordination is explicit in three places. A USB capture wins: DsCapture.startUsb already calls releaseDevice at claim time, that closes the slot, and the close now also takes the sensor listeners off — the claim makes the InputDevice vanish anyway, but going through the explicit teardown is what makes the ordering deterministic instead of a race against the platform's own removal callback. The phone-gyro mirror stands down: registering flips a bit the router reports through padHasOwnMotion, which DeviceGyro re-reads on every sample and answers with its own zero park. And a pad with an accelerometer but no gyroscope is deliberately NOT taken — it could only send gravity while pinning rotation at zero, on a pad the mirror is otherwise entitled to speak for, which is the same fight in a quieter costume. The wire units are measured fact (punktfunk_core::input::gamepad: 20 LSB/deg·s, 10000 LSB/g), and they now live in exactly one place on this client: Gamepad.motionGyroWire / motionAccelWire, which DeviceGyro was hand-inlining a second copy of. The gyro program's first finding was a client sending 40x hot because a second copy of a number had drifted, and the merge that followed found a sender nobody remembered to correct. One function, both callers. THE AXIS FRAME ON THIS PATH IS NOT VERIFIED, and the mapping is deliberately straight through rather than guessed at. What is known: the wire is a unit passthrough into a virtual DualSense report, and that report's frame was measured over raw HID on 2026-08-07 as (Right, Up, Backward-toward-the-player) carrying (pitch, yaw, roll), right-handed — which is why the USB path forwards the pad's own order un-remapped and is correct to. Android documents its sensor frame for a handheld device as +x right, +y up, +z out of the face, the same frame once "the face" is read as the one the player looks at. So straight through is what the documentation implies. What nobody has done is put a Bluetooth DualSense in front of the platform sensor framework and compare — those numbers come through a HID driver and InputFlinger's sensor mapper, either of which could permute or negate without saying so. A plausible-looking wrong remap is exactly the bug this program keeps finding, so the code says unverified and names the measurement that settles it, and each feed logs its first converted sample so the cheapest half of that measurement — which slot gravity lands on with the pad flat and still — costs a logcat line. PadSensorsTest pins the scale, the clamp, the rounding and the straight-through order, mutation-checked four ways: 20 to 16 fails gyroScaleFromRadiansPerSecond and straightThroughFrame, reversing the axis order fails straightThroughFrame, truncating instead of rounding fails roundsToNearestNotTowardZero, and negating the accel fails restingPadIsTheHostNeutral. Its frame expectations are written to change together with any remap that lands, not to be edited around one. GamepadRouter needs Android and a live JNI handle and there is no Robolectric here, so its half is argued in comments beside the code, as DsCapture's claim ordering already is. Gates: kit 65 tests (58 before, plus 7), app 67 unchanged, 0 failures, read out of the JUnit XML rather than off a green build. |
||
|
|
979ed8f426 |
docs(clients/pads): say that an X-Box virtual pad has nowhere to put motion
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.
|
||
|
|
1eab4b6626 |
fix(client/apple): the phone-gyro mirror was left on the old motion convention
ci / docs-site (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m30s
windows-drivers / driver-build (pull_request) Successful in 1m39s
ci / web (pull_request) Successful in 2m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 3m21s
android / android (pull_request) Successful in 4m54s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m6s
ci / rust (pull_request) Successful in 9m4s
windows-drivers / probe-and-proto (pull_request) Failing after 10m10s
Surfaced by the merge. `DeviceGyro`'s header states the contract plainly — "units and axis semantics match `GamepadCapture.forwardMotion` exactly … the same convention, so a future sign/scale correction lands in one place for both sources" — and this branch made two such corrections in only one of the two places. That is a promise the code stopped keeping the moment the controller path was fixed. Both were true parity when #88 was written; both broke here. **The negation.** `GamepadCapture` sends `-(gravity + userAcceleration)` because Apple reports the gravity VECTOR, pointing down, while an accelerometer measures proper acceleration, pointing up at rest — and the wire carries the latter. The mirror sent it un-negated, so a phone lying still told the host it was accelerating downward at 1 g. The comment above that line even claimed the convention matched. **The frame.** The mirror's remap targets the controller frame its own header describes — x right, y up, z out of the screen — which is exactly GameController's frame, and that is not the DualSense report frame the wire is defined in. So the same change of basis the controller path now takes applies here, after the orientation remap rather than instead of it: the remap resolves which way the phone is being held, and the basis change translates the result into the pad's language. Two different jobs that happen to compose. Order matters for the closing sample too. `stop` replays `lastAccel` beside a zero gyro so "rotation stopped" does not also read as free fall; `lastAccel` is recorded after both conversions, so what gets parked is what was actually sent. Left alone deliberately: `DeviceGyroRemap` itself and `DeviceGyroRemapTests`. The orientation matrices answer a different question — which way is the phone being held — and nothing measured this evening bears on them. They remain derived-not-verified, as their own doc says, and the on-glass pass that owes the controller path a check owes them one too, in all four orientations. Gate: macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck green — the latter is what actually compiles this file, since the whole thing is `#if os(iOS)`. |
||
|
|
5a4305c072 |
merge: bring current main into the gyro correctness branch
main moved ~60 commits while this branch was in progress, and one of them matters here: PR #88 (the phone-gyro mirror) landed, touching the same motion path. One conflicted file, `GamepadCapture.swift`, in three places — all of them the two changes meeting rather than disagreeing: - **Slot fields.** #88 added `motionSent` + `lastAccel` for its flush-parks-motion fix; this branch removed `lastMotionNs` with the 4 ms drop-throttle. Kept both decisions: the parking state stays, the throttle field goes. - **forwardMotion's head.** #88 added the mirror stand-down (`pad 0` yields while the phone speaks for it); this branch deleted the throttle guard. Kept the stand-down, dropped the guard. - **The send.** This branch converts into the DualSense report frame; #88 records what went out so `flush` can replay it beside a zero gyro. Both, with the recording placed AFTER the conversion — `flush` replays `lastAccel`, so it has to be the vector that actually went on the wire, or a still pad's gravity gets parked in the wrong axis. The two features compose exactly, which is worth stating because it is not luck: this branch gates motion capture on `hasRotationRate`, and #88 engages the phone mirror when `hasRotationRate != true`. They are complements — a pad either drives its own gyro or the phone mirrors for it, never both and never neither. Everything else auto-merged. Note `DeviceGyroRemapTests` is `#if os(iOS)`, so the macOS suite reports the same 215 as before the merge rather than gaining #88's six — checked, not assumed. Gates re-run against the merged tree rather than trusting either side's: Linux fmt + build + `clippy --locked --all-targets -D warnings` + punktfunk-core and pf-inject suites; Apple 215 tests and the iOS-triple typecheck; Android kit + app compile and tests. All green. |
||
|
|
0170da2a5f |
fix(client/apple): stop dropping rotation, and stop inventing it
ci / bun-nix (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m3s
ci / docs-site (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m19s
windows-drivers / probe-and-proto (pull_request) Successful in 33s
android / android (pull_request) Successful in 3m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m15s
ci / rust (pull_request) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m9s
G13 — the three capture-fidelity findings from the gyro sweep, two fixed and one argued. **The 4 ms floor was a DROP, and it was shedding real rotation.** A sample arriving 3.9 ms after the last one was discarded outright. That is the wrong shape for this signal: buttons and sticks are absolute state, so a dropped frame costs nothing — the next one says everything it would have. Angular velocity is a RATE, and a consumer integrates it into an angle, so a dropped sample is rotation that happened and can never be recovered. GameController's delivery jitters around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra sample; it sheds a steady fraction of every turn. And the error is one-signed, so it accumulates — aim drifting short, which reads as bad sensitivity rather than as a bug. Nothing needed the ceiling. GC delivers at the sensor's rate rather than faster, the SDL client has always forwarded every sample, and the host's idle watchdog is a 100 ms timeout this cannot outpace. The throttle's two fields went with it: `lastMotionNs` was left set-but-never-read once the guard was gone, and `motionIntervalNs` had no other consumer. (Notes elsewhere say `flush` parks motion and reads it — that is PR #88's branch, not this one. Checked rather than assumed.) **An X-Box pad was streaming gyro it does not have.** Capture attached to any `GCMotion`, and an X-Box controller exposes one that reports gravity and NOTHING else. So the client sent a permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration saying this pad has one. That is worse than having no motion plane at all: a game sees a controller being held perfectly still forever, and there is nothing to fall back to and nothing to notice. Now gated on `hasRotationRate`, which is GameController's own answer to the question we actually mean. The settings badge had the same bug from the same cause — `hasMotion` was `motion != nil`, so an X-Box pad got a gyroscope icon. It now reads `hasRotationRate` too. One wrong predicate was driving both the UI promise and the wire behaviour, which is why they were wrong together. That also simplifies G8's "your gyro can't reach this session" notice, which had to test `hasRotationRate` itself to avoid nagging about a gyro the pad never had. With the attach gated on it, the notice is just the else-branch. **Motion stays on the main queue, and this is the argument for why.** GameController's `handlerQueue` is a property of the CONTROLLER, not of an element, so moving motion off main moves buttons, sticks, the touchpad and the escape chord with it. This class is `@MainActor` throughout — eight `assumeIsolated` sites, the slot table, the gesture timers — so that is a rewrite of the isolation model rather than a queue assignment, and it would put the tvOS escape chord (the only controller way out of a stream there) on a background queue. That is a real risk for a speculative gain. The comment says so at the call site, and names the measurement to make first if it ever does bite: the host's per-pad motion inter-arrival histogram already reports exactly this and would say whether the delay is client-side or on the wire. Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck green. No test pins the throttle removal or the capability gate: both are properties of live `GCMotion` delivery, which this module cannot fake — there is no injectable seam, and inventing one to assert "we called sendMotion twice" would test the mock. They are argued at the call sites instead, in the same spirit as the parts of `DsCapture` that are not unit-testable in their module either. On-glass verification is owed with the two already outstanding on that rig. |
||
|
|
d996449a82 |
fix(host/pads): a virtual pad at rest said it was in free fall
G14, unblocked by the frame measurement in
|
||
|
|
efb7f99129 |
fix(client/apple): motion arrived in the wrong frame — measured against a real pad
G16 step 1, and the second half of what |
||
|
|
7cab7ae6bc |
feat(client/android): say when a captured pad's gyro can't reach the session
G8's Android half, and the last of the three clients. Same failure as the other two: a controller with a gyro, in a session whose virtual pad has no motion plane, does nothing when tilted — silently, with no way from the couch to tell that apart from a broken sensor. The fix is the Controller type setting, so the notice names it. Android read neither the requested nor the resolved backend, so this needed a plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches` takes the kind a pad declared and answers off `pad_motion_reaches` in punktfunk-core, where the argument and the tests already live. The rule is subtler than it looks — the host builds each pad from its OWN declaration and folds what it cannot build, so neither the declaration nor the session echo answers it alone — and every way of getting it wrong is silent. A Kotlin transcription would have been a third thing to keep in step with the host, which is exactly how the SDL half got it wrong the first time. Asked once per pad, at claim, in `openExternal` — where the pad's kind is already being declared to the host — and the answer held for the pad's lifetime on the `ExternalPad`. Not per sample: this runs at a DualSense's full report rate. `hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true — every pad it captures is a Sony one whose IMU is a headline feature, forwarded on the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's motion rides inside the opaque passthrough report that `hidReport` carries, which nothing here may second-guess: warning about motion for a pad that never calls `motion()` would be a notice about a feature the player never lost. The suppression itself is on `motion()` regardless, where it costs a dead pad nothing and stops a live one paying to send samples the host will decode and discard. The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord confirmation at the top. The two can coincide — a pad is claimed at roughly the moment someone might be muting — and one landing on the other would cost the user both. It holds 6 s rather than the mic chord's 1.6: that one confirms something the user just did, this one explains something they did not, in a sentence they have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same reason those are — a slot closing during release must not poke Compose state on the way out. Not covered by tests, and this is a limit of the module rather than a choice: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric here, and the predicate it defers to is pure Rust that already has its table. So the parts that carry the reasoning are argued in comments, as `DsCapture`'s claim/teardown ordering already is. What IS mechanically verified is the piece that a compiler cannot catch and a device would fail on: the JNI symbol `Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and global in the built arm64-v8a `.so`, so the `external fun` resolves rather than throwing `UnsatisfiedLinkError` at the first pad. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed, read out of the JUnit XML rather than inferred from a green build — unchanged from this branch's previous count), `:app:compileDebugKotlin` and `:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease` rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on it. On-glass verification is owed on the rig the earlier legs used, and is worth doing as one pass with the two already owed there. |
||
|
|
aaa58ad817 |
feat(client/apple): say when a pad's gyro can't reach the session, and stop powering it
G8's Apple half — the UI hint
|
||
|
|
5c4969fd6b |
fix(client/pads): the gyro cut-off asked about the session, not the pad
Supersedes the check |
||
|
|
8e8d30202c |
fix(client/android): a Sony pad's buttons no longer wait on its calibration
Supersedes the parse gate in |
||
|
|
26b0819f5c |
fix(client/android): plugging in a Sony pad could hitch the interface
Supersedes the synchronous calibration read |
||
|
|
f6de620f34 |
fix(client/android): a captured Sony pad's gyro turned the wrong amount
G14/G16 leg 3. This supersedes the nominal constant |
||
|
|
0e40b374e7 |
fix(client/android): DualSense acceleration arrived ~18% short
G16 leg 2. A DualSense over USB to an Android phone, streaming to a Linux host, flat and face up: |accel| = 0.811 g where 1.000 is owed. Magnitude is frame-invariant, so this is unambiguous regardless of the separate axis question below, and it came from a 27-second static average — no sampling error in it. `DsDevice` said so plainly: "Gyro/accel stay in raw device units". It read the i16s out of the pad's report and forwarded them verbatim. But raw device units are not wire units — the wire is fixed at 10000 LSB/g and the pads' native resolution is the 8192 that hid-playstation calls DS_ACC_RES_PER_G. 8192/10000 = 0.819 predicted against 0.811 measured. Acceleration is now rescaled on both the DualSense and DualShock 4 parse paths, clamped because the multiplier is >1 and a real near-full-scale slam would otherwise wrap the i16 into an impossible acceleration in the opposite direction. Two things deliberately NOT done. Gyro is left alone. It is almost certainly low by the same mechanism, but it cannot be corrected with a nominal constant the way acceleration can: the still average shows this pad's accel calibration is near-identity (~1% off), while the gyro's emphatically is not — a near-identity gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. Fixing gyro means reading the pad's calibration feature report and applying its own numbers, which also removes acceleration's residual 1% bias. `HidUsbLink` can SET_REPORT but has no GET_REPORT path yet, so that is a real change rather than a constant, and it is owed. I tried to pin the gyro factor by integrating the on-glass rotations instead: a nominal 90 deg yaw integrated to ~88.5 deg through the Apple client (correct) and ~62.7 deg through Android. Directionally consistent, but the readout samples at 5 Hz and a ~1 s rotation is badly undersampled, so that ratio is not a constant anyone should ship. Recorded, not used. The axis frame is also left alone. This leg puts gravity on Y where the Apple leg put it on Z, so at least one client's frame is wrong — but Android forwards the pad's own axis order un-remapped, which makes its reading evidence about the hardware rather than about us, and resolving it needs the bare-metal reference reading G16 step 1 calls for. Every bare-metal Linux box was unreachable (Deck down, HTPC down, .25 is another KVM guest). Rescaling does not touch axis order, so this fix stands however that resolves. Gate: `:kit:compileDebugKotlin` and `:kit:testDebugUnitTest` green, JNI libs built clean at the API-28 floor across 3 ABIs. On-glass re-verification owed: re-run the at-rest reading and expect 0.99-1.00 g. |
||
|
|
9e9bb9f466 |
fix(client/apple): acceleration was upside down — measured on glass
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.
|
||
|
|
cbfa03b7ad |
docs(host/pads): the SC2's bInterval is already 1 kHz — don't "fix" it to 250 Hz
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.
|
||
|
|
77797a9e20 |
feat(client/pads): stop streaming gyro into a session that cannot receive it
G8 of the gyro program, SDL-client half. The `Welcome` has always carried the backend the host actually RESOLVED, which is not necessarily the one the client asked for — Auto lands on Xbox 360 for anything not Sony/Valve/Xbox, and a Switch Pro on a Windows host folds to X360 too. No client read the field. So a player with an 8BitDo, or a Switch Pro on Windows, got a controller whose gyro did nothing, with nothing anywhere saying why: the client shipped ~250 Hz of Motion datagrams and the host parsed and discarded every one. `GamepadPref::has_motion()` answers whether a backend has a motion plane at all. The SDL client checks it on the first gyro sample: it logs one line naming the resolved backend and pointing at the fix (pick a DualSense-class controller type), then stops sending. Once per slot, not per sample — this path runs at the pad's sensor rate. `Auto` deliberately answers true. It means "unknown" — an old host that omitted the echo, which may well have resolved a DualSense — and suppressing motion on unknown would silently break working gyro, a worse failure than sending datagrams nobody reads. The predicate is an exhaustive match so a new backend has to state its answer rather than inherit one, and a table test pins both halves: a false negative kills working motion, a false positive keeps the void open, and both are silent. Owed: the plan wants this surfaced as a one-line UI hint, not just a log line. Apple already stores `resolvedGamepad` and Android needs the plumb; neither is done here, and both want their own gate. Gate (Linux CI image): fmt, build, clippy --all-targets -D warnings, and the test suites — green, with the new capability test observed running. |
||
|
|
ce5047f3ad |
fix(host/pads): the Windows driver stops halving motion and stops serving torn reports
G6 + G15 of the gyro program. G6 — the UMDF gamepad driver's input path. Its timer ran at 8 ms and completed one pended READ_REPORT per tick, so a game could observe at most ~125 Hz while clients stream motion at ~250 Hz: every other sample was overwritten in the slot before anything read it, and the ones that survived carried up to 8 ms of extra latency. For gyro, a dropped sample is not a dropped frame — it is rotation that never reaches the game. The timer now ticks at 2 ms (about a real DualShock 4's Bluetooth cadence). Only the cheap half runs on every tick: read the input slot, complete one pended read. The channel handshake and the health marks stay on their historical ~8 ms, because they cost more, nothing wants them faster, and `driver_heartbeat`'s documented "+1 per ~8 ms tick" is what the host reads as liveness. The same slot is a single unqueued buffer that both sides touch without a lock, so a driver read landing mid-copy handed the game a report that was half the previous frame and half the next. For a button that is a one-tick glitch; for motion it is a spike in angular velocity, which an integrator turns into aim movement. `PadShm` gains an `input_gen` seqlock (v2.3, carved from reserved space inside the v2 legacy region): the host takes it odd, fences, writes the 64 bytes, and stores it even; the driver samples it either side of its read and retries once. The old code's own comment called this out as a known residual — it is now closed rather than documented. Version posture matches the ring's, with one simplification: no capability stamp is needed, because an old host never writes the field and a constant 0 is indistinguishable from "no write in flight", so a new driver against an old host behaves exactly as it does today, and an old driver ignores the field entirely. The Steam Deck write path had neither the seqlock nor even the trailing Release its DualSense sibling carried; all three Windows backends now publish through one `publish_input`. G15 — motion-cadence observability. The host already computed the measurement a "gyro feels floaty" report needs (client inter-arrival percentiles), but kept ONE global accumulator, so two motion-capable pads in a session interleaved into each other's gaps and produced a number describing neither. It also sat at `debug` behind a `tracing::enabled!` check, so a field log arrived with nothing in it and the only way to get the measurement was to ask for a re-run. Now per-pad and always on, summarized at `info` when the session ends — the moment a field report is being written. It costs one subtraction and one array increment per sample: percentiles come from a fixed log2 histogram instead of a growing sorted Vec, so there is no allocation, no per-window sort, and no way for a client streaming as fast as the link allows to make the instrument expensive. Percentiles are reported as bucket upper bounds (`_le`), which is a factor-of-two answer to a question whose answers are orders of magnitude apart. Gaps of 500 ms or more are counted as stalls rather than folded into the percentiles — an interruption is not a cadence, and averaging it in would report a healthy feed as a terrible one. Gates. Windows CI runner .133, the drivers workspace on the real WDK: cargo build, clippy -D warnings (which enforces the unsafe-audit lints), and fmt — all green, against a source whose SHA-256 matches this commit's. Linux CI image: fmt, build, clippy --all-targets -D warnings over pf-inject / punktfunk-core / punktfunk-probe / pf-client-core / pf-driver-proto / punktfunk-host, and the test suites including the 5 new motion-cadence tests — all green. Not measured on glass. G6's stated gate is a sensor-rate reading (SDL testcontroller or Steam's calibration screen) that matches the client's send rate; that is still owed, and a driver change only a compile has seen deserves it before anyone trusts the number. |
||
|
|
4834c2ee51 |
fix(host/pads): DualShock 4 gyro ran 40× fast, and no pad ever stopped turning
Phase 1 of the gyro program (design/gyro-program.md, G1-G5) — the five correctness fixes under it. Gyro aim integrates angular velocity over time, so each of these is not a cosmetic wrongness: a wrong scale is every rotation being the wrong size, a wrong clock is every rotation being integrated against a fictional dt, and a stale sample is rotation that never happened. G1 — the DualShock 4 calibration blob. A Sony pad does not assume a motion scale, it reads one out of a fixed calibration feature report. Ours declared 0.5 LSB per °/s and 8192 LSB/g while the wire delivers 20 and 10000, so every DS4-type session decoded gyro 40× too fast and acceleration 1.22× hot — since the backend shipped. The blob now states the wire's own units (the DualSense blob's numbers, deliberately: both pads consume the identical wire sample). Its interleaved per-axis order is NOT a bug and stays: the virtual pad declares BUS_USB, where interleaved is the correct layout; grouped is Bluetooth's. The same blob lives a second time in the UMDF driver, which is a separate WDK workspace that cannot depend on pf-inject — one wrong table in two files, where fixing one reads as fixing it. Both are fixed, and the DS4 feature reports now live in dualshock4_proto beside the DualSense's rather than in the Linux backend, so there is one canonical copy to point at. Field hosts keep the old blob until they update the host package. G2 — the gate that would have caught it. Nothing pinned any backend's declaration against the wire, so tests/motion_contract.rs now applies the CONSUMER's arithmetic (the kernel's, and SDL's, which differ) to each backend and asserts the result lands back on the wire constants — for the DualSense and DS4 blobs, and for the Deck and Switch Pro rescales. It also parses the driver's Rust source and re-derives the units from THAT, so the two copies cannot drift. Verified non-vacuous both ways: re-introducing the old blob fails with "declares a fractional 32/64 LSB per °/s", and reverting only the driver's copy fails with "the UMDF driver's DS4_FEATURE_CALIBRATION has drifted from pf-inject's". The wire units themselves move to punktfunk_core::input::gamepad, referenced by the client's capture scale, the Deck/Switch rescales, and the probe — whose at-rest vector said 16384 (a driver's number, not the wire's) and now says 1 g. G3 — real sensor clocks. The DualSense advanced its sensor timestamp by +1 raw unit per report (0.33 µs — a frozen clock) and the DS4 by a flat +188 (~1 ms) regardless of the real 4-8 ms cadence. Anything integrating rate × dt off that field got nonsense. All four backends now stamp elapsed monotonic time in their own units via a shared SensorClock, anchored to the pad's first report so an irregular publish loop cannot make it drift, and truncated to the field width — which reproduces the wrap real hardware does. G4 — motion is level-triggered and had no watchdog. merge_frame preserves the last sample and the heartbeat re-emits it, so a feed that stops leaves the pad rotating forever — and with G3's honest clock, at a dt that keeps growing. Rumble and the pen plane each have an idle timeout; motion now has one too, at 100 ms. Angular velocity only: acceleration is kept, because gravity is legitimately persistent and blanking it reads as free-fall. The SDL client parks its gyro at zero when a slot closes, which is the case we can flush rather than wait out. (The Apple half of this rides in PR #88.) G5 — a pad returning inside the 300 ms replug grace keeps the same device and skips the create path, so a different controller inherits the previous one's touch contact and rotation — and a pad with no gyro never sends a sample to correct it. sweep() now reports re-claims separately from drops, and the manager clears the rich plane on one. Rich fields only: rumble and hidout dedup deliberately survive a removal. Gates (Linux, CI image): fmt, build, clippy --all-targets -D warnings over pf-inject/punktfunk-core/punktfunk-probe/pf-client-core, and the test suites — 110 pf-inject unit + 6 contract + 29 pf-client-core gamepad, all green. Not yet verified on glass; the on-glass sign/scale session is G16. |