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
Owner

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/accel straight 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:

frame
DualSense report (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll
GameController (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-playstation on a real host.

path at rest rotations signs
Apple (macOS client, same GamepadCapture as iOS) y=+0.983, |a|≈1.000 all slots correct 47/47
Android USB (DsCapture) y=+0.992, |a|≈1.010 — was 0.811 all slots correct 38/38
Android Bluetooth (PadSensors, new in G10) y=+0.991 all slots correct 95/100

Two independent confirmations fell out of this. The Android calibration read logged gyro 16/16/16 LSB/°·s, accel 8177/8188/819316 proves the pad's real blob was read rather than the nominal fallback (which reads back as exactly 20), and those accel numbers are byte-identical to the Mac's independent hidapi read 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; 5c4969fd fixes 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:

  • DeviceGyro promised a sign/scale correction "lands in one place for both sources"; only GamepadCapture had been corrected, leaving the mirror sending acceleration un-negated (1eab4b66).
  • G10's new Bluetooth path forwarded motion unconditionally, reintroducing exactly the void G8 had just removed (7a4cdac5), plus the last duplicate scale constant in that module.
  • And one error of my own: 1eab4b66 also 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 in ee61e8c9; 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.
  • Four design decisions remain open and are the maintainer's: Switch×Windows folding, Auto for generic gyro pads, wire-v2 scale, Moonlight scope.
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`/`accel` straight 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: | | frame | | |---|---|---| | DualSense report | `(Right, Up, Backward)` | axis 0 pitch, 1 yaw, 2 roll | | GameController | `(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-playstation` on a real host. | path | at rest | rotations | signs | |---|---|---|---| | **Apple** (macOS client, same `GamepadCapture` as iOS) | `y=+0.983`, \|a\|≈1.000 | all slots correct | 47/47 | | **Android USB** (`DsCapture`) | `y=+0.992`, \|a\|≈1.010 — was **0.811** | all slots correct | 38/38 | | **Android Bluetooth** (`PadSensors`, new in G10) | `y=+0.991` | all slots correct | 95/100 | Two independent confirmations fell out of this. The Android calibration read logged `gyro 16/16/16 LSB/°·s, accel 8177/8188/8193` — `16` proves the pad's real blob was read rather than the nominal fallback (which reads back as exactly `20`), and those accel numbers are **byte-identical to the Mac's independent `hidapi` read 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; `5c4969fd` fixes 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: - `DeviceGyro` promised a sign/scale correction "lands in one place for both sources"; only `GamepadCapture` had been corrected, leaving the mirror sending acceleration un-negated (`1eab4b66`). - G10's new Bluetooth path forwarded motion unconditionally, reintroducing exactly the void G8 had just removed (`7a4cdac5`), plus the last duplicate scale constant in that module. - **And one error of my own**: `1eab4b66` also 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 in `ee61e8c9`; 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. - Four design decisions remain open and are the maintainer's: Switch×Windows folding, Auto for generic gyro pads, wire-v2 scale, Moonlight scope.
enricobuehler added 15 commits 2026-08-07 17:20:48 +00:00
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.
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.
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.
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.
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.
G14/G16 leg 3. This supersedes the nominal constant 0e40b374 shipped, which was
always labelled a stopgap.

Measured on glass 2026-08-07: a DualSense over USB into an Android phone,
streaming to a Linux host, flat and face up, arrived as |accel| = 0.811 g where
1.000 was owed. The parse forwarded the pad's raw i16s verbatim, and raw device
units are not wire units. 0e40b374 rescaled acceleration by the nominal
10000/8192 and deliberately left gyro alone, because a constant provably cannot
fix gyro: the same still average showed this unit's accel calibration is
near-identity (~1% off) while its 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. That scale is per unit, and the only thing that knows it is
the pad.

So the client now asks. HidUsbLink grows a GET_REPORT path — EP0, the exact
mirror of the SET_REPORT it already had — and DsCapture reads the pad's IMU
calibration feature report ONCE, while claiming it: 0x05 / 41 B on a DualSense or
Edge, 0x02 / 37 B on a USB DualShock 4. DsDevice.MotionCal then applies
hid-playstation's own arithmetic per axis, which is the same math the host's
contract test (crates/pf-inject/tests/motion_contract.rs, SonyImuCalibration)
reads from the other end: gyro raw × speed_2x × 20 / (|plus−bias| + |minus−bias|),
accel (raw − (plus − range/2)) × 20000 / range. Long arithmetic, because the gyro
multiplier overflows an Int, and clamped, because both are >1 multipliers and a
full-scale flick would otherwise wrap the i16 into a motion in the opposite
direction. Reading the blob also removes acceleration's residual ~1% factory bias
that the nominal constant left behind.

Once at claim and never per report. EP0 is independent of the interrupt endpoints
so the read is safe alongside the reader thread, but a blocking control transfer
in the report path would wreck capture latency, and the calibration is fixed for
the life of the connection anyway. The capture logs the derived resolutions, which
is the discriminator for whether a blob was read at all: a real pad declares ≈16
LSB per deg/s, the fallback reads back as exactly 20.

A pad that refuses, answers short, or declares zeroes (a clone, a broken unit)
keeps today's behaviour per axis — nominal accel, gyro straight through. Nothing
here ever zeroes motion: slightly mis-scaled beats silent.

Not covered. The axis frame is still untouched: this leg puts gravity on Y where
the Apple leg put it on Z, so at least one client's frame is wrong, and settling it
needs the bare-metal Linux reference reading G16 step 1 calls for. Rescaling is
frame-independent, so it stands however that resolves — remapping is not, so it
stays out. Bluetooth's grouped plus/minus layout is not implemented either: this
path is USB-only by construction (Android exposes no raw path to a Classic pad),
and a half-used generalisation would be a latent bug rather than a feature.

Gate: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` green, 16 DsDeviceTest
cases run 0 failed, and the five new ones were confirmed present in the JUnit XML
rather than merely compiled. Non-vacuity checked by mutation — perturbing the gyro
conversion fails 6 tests, including all four new ones that assert a number.

On-glass re-verification owed, on the rig that measured the defect (DualSense →
USB → phone → 192.168.1.21): at rest |a| = 1.00 g exactly via ~/gyroscope.py, and
a nominal 90 deg yaw integrating to ~90 deg via ~/integrate.py — the same 90 deg
that read ~62.7 deg before this change.
Supersedes the synchronous calibration read f6de620f shipped an hour ago. The
ordering it protected is kept; the blocking it cost is not.

f6de620f read the pad's calibration inline in DsCapture.startUsb, which runs on
the main thread — the stream's setup path, and the USB-permission broadcast. The
read is a blocking EP0 control transfer: a pad that is there answers in about a
millisecond, but a pad that is stalling takes the link's whole 250 ms write
timeout, and either way the interface was waiting on a controller. That is the
wrong thread for it.

It now runs on its own daemon thread, one per claim, named pf-ds-cal — the same
shape HidUsbLink already uses for its reader rather than a second style. A
pathological stall now delays the pad's motion by a moment instead of freezing
the UI.

What kept the ordering honest before was "assign the calibration before `model`",
since `model` is what lets the link thread into the parse. That reasoning stands,
so the gate simply moved: MotionCalHandoff holds the claim's calibration, starts
null, and onReport parses nothing until it lands. No report is ever scaled by the
last pad's numbers — those are per unit — nor by the nominal fallback the real
read is about to replace. Dropping the first millisecond of a capture costs
nothing: the reports carry absolute state, so the next one says everything the
dropped one would have.

The calibration is what got deferred, not `model`, and that is deliberate.
Keeping `model` synchronous keeps isActive, the teardown writes, the feedback
sinks and the active-changed true/false pairing meaning exactly what they meant
yesterday — and, more to the point, it makes a late completion structurally
unable to resurrect a dead capture. A straggler can only ever publish a
calibration, and nothing is parsed while `model` is null.

Teardown, which is where this sort of change actually bites. Both stop() and the
unplug path end the claim before they close anything: ending burns the token, so
a read that lands afterwards publishes nothing and says so in the log. They then
wait, bounded at 500 ms and normally already over, for the read to let go of the
connection they are about to close — closing a descriptor with a transfer in
flight pulls it out from under the kernel, the same rule the pad-audio borrow
follows. It cannot deadlock: the reading thread blocks on the EP0 transfer and on
the hand-off's own monitor, never on anything a teardown holds. If a pad has
stopped answering entirely the wait elapses and teardown proceeds regardless,
which is the same exposure the feedback writes already carry and better than an
interface that never comes back.

Tested where it is testable. MotionCalHandoff is the piece that carries the
hazard and it is pure, so it has its own test: nothing is visible until the read
lands, a read that outlived its claim publishes nothing, a re-claim never
inherits the previous pad's calibration, and a doubled end still refuses every
outstanding token. Mutation-checked both ways — deleting the token check fails 3
of them, deleting begin's clear fails the fourth.

Not covered: DsCapture's own claim/teardown ordering is not unit-testable in this
module — there is no Robolectric, and the class builds a main-Looper Handler and
needs a UsbManager — so it is argued in comments rather than pinned. The on-glass
re-verification f6de620f owes is unchanged and still owed.

Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (61 cases across the
module, 0 failed) and `:app:compileDebugKotlin` green, with the four new cases
confirmed present in the JUnit XML rather than assumed from a green build.
Supersedes the parse gate in 26b0819f. The off-thread read, the claim token, the
teardown ordering and its bounded wait all stand — only what happens in the gap
changes.

26b0819f held every report back until the calibration read came home, so a pad
that stalled on EP0 could feel dead for up to the link's 250 ms timeout: no
buttons, no sticks, nothing. Reports are now forwarded immediately and their
motion scaled by the nominal calibration until the real one lands.

That gap is exactly the behaviour that shipped before f6de620f — acceleration
~18% short, gyro unscaled — for about a millisecond. Nobody can feel that. A
controller that ignores a button press for a quarter of a second is not in the
same category, and it is the only one of the two a user would ever report.

It is also the safer of the two conservatisms available here. The rejected third
option, forwarding motion as zeroes until the real numbers arrive, would have the
host read a still pad as being in free fall — a lie about the physical world
rather than an imprecision about it. The nominal constants are merely a slightly
wrong scale.

The token is more load-bearing under this, not less. With a gate, an unpublished
calibration meant "parse nothing"; now it means "scale nominally", so begin()
clearing the previous pad's value is the whole reason a re-claim falls back to
the nominal constants instead of silently inheriting factory numbers belonging to
a different unit — which are, in general, further off than nominal. The fallback
therefore lives in the hand-off itself (MotionCalHandoff.effective) rather than
as an elvis at the call site: restoring the gate now means changing the type's
API, not deleting three characters in onReport.

The tests moved with the contract. They assert the nominal calibration is what is
in effect during the gap, rather than merely that the slot is empty — an empty
slot is now compatible with either behaviour, so asserting on it would have let
a regression pass. Added the case the change exists for: the same raw report,
parsed either side of publication, forwards identical buttons and sticks while
its gyro and acceleration convert differently. Mutation-checked three ways —
dropping the nominal fallback fails all five cases, dropping begin's clear fails
the inheritance case, dropping the token check fails three.

Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` and
`:app:compileDebugKotlin` green on a forced clean rerun, 62 cases across the
module, 0 failed, with the five hand-off cases read back out of the JUnit XML.
The on-glass re-verification f6de620f owes is still owed and unchanged.
Supersedes the check 77797a9e shipped an hour ago. The suppression, the
log-once, and the "unknown must not suppress" rule all stand; the field it reads
does not.

77797a9e read `Welcome.gamepad` — the backend the host resolved for the SESSION
— and stopped sending motion when it had no motion plane. But the host does not
build pads from that. It builds each virtual device from that pad's own
`GamepadArrival` (`Pads::set_kind`) and falls back to the session default only
for a pad that never declares one, which is precisely why `declared_kind` exists
and why its doc comment says an explicit setting has to be re-declared per pad.

So the check had a false negative, and it is an ordinary living-room setup. Under
"Automatic" the Hello carries the ACTIVE pad's kind (`auto_pref`), so a couch
with an X-Box pad on slot 0 and a DualSense on slot 1 echoes Xbox360 — while the
host, reading pad 1's arrival, builds it a DualSense with a working motion plane.
The old check read the echo, saw no motion plane, and killed pad 1's gyro. That
is the exact failure 77797a9e's own commit message names as the worse of the two
("a false negative kills working motion"), introduced by the fix for the other
one.

The question is per pad, so the slot now carries what it declared, beside the
physical `pref` it already held. The two are deliberately separate fields
answering different questions: `pref` is the controller in the user's hands, which
is what the local feedback paths must keep reading, and `declared` is the one the
host is pretending to have.

Three facts decide the predicate, and they are written out in
`pad_motion_reaches` rather than at the call site because all three clients need
the same reasoning:

- the echo is not this pad's answer when the pad declared something else;
- the host FOLDS what it cannot build — a Switch Pro on Windows, any UHID backend
  on a host whose /dev/uhid is unusable — and nothing client-side can predict it;
- but the echo IS one observed sample of that fold, for the kind the Hello asked
  about, so it is authoritative for a pad that declared exactly that.

Hence: trust the echo when declared == asked, else fall back to the declaration.
That keeps both motivating cases — a generic pad under Automatic (declares X-Box
360, suppressed, the sweep's H5c) and an explicit Switch Pro folded to X-Box 360
by a Windows host (declared == asked, so the echo catches it, H5d) — where either
field alone gets one of them wrong. `requested_gamepad` is kept on the client
next to `resolved_gamepad` for this: the pair is what makes the echo usable per
pad, and a lone field would only tempt the next reader back into the session-level
question.

The residual gap is a pad whose declared kind differs from the session's AND gets
folded: we keep sending and the host keeps dropping. That is the direction to be
wrong in, and it is what the session-level check was worth in the first place —
wasted datagrams, not a dead gyro.

Non-vacuity proven both directions rather than assumed. Reverting to
`resolved.has_motion()` fails on the mixed-pad row; reverting to
`declared.has_motion()` (no echo at all) fails on the Switch-Pro-on-Windows row.
Each case in the table is a session someone can actually sit down to, and the
comment on each says which of the three inputs decides it.

Gate (Linux CI image, pf-lxcheck2): fmt, `build -p punktfunk-core`, `build -p
pf-client-core`, `clippy --locked --all-targets -D warnings`, and both test
suites — green, with the new case observed in the run's own `... ok` line rather
than inferred from a green gate, and pf-client-core's 163 unchanged.
G8's Apple half — the UI hint 77797a9e left owed, plus the suppression, which on
this client is worth more than it was on the SDL one.

The failure being fixed is entirely silent. A controller with a gyro, in a session
whose virtual pad has no motion plane, simply does nothing when tilted: nothing
in the app says so, and from the couch a session that resolved an X-Box backend
is indistinguishable from a broken sensor. The fix is the Controller type setting,
so the hint has to name it — a badge that only said "motion unavailable" would
leave the player exactly as stuck.

Asked per pad, off what the slot declared, via the predicate punktfunk-core now
carries. `GamepadCapture` is the one client where this is naturally per pad
already: `openSlot` computes `manager.declaredKind(for:)` and puts it in
`slot.pref`, so the question is answered where the pad is opened rather than on
every sample. `GamepadType.motionReaches(declared:asked:resolved:)` is static and
pure so it can be tested without a live session; the connection's instance method
fills in the two halves it owns, and `requestedGamepad` is stored beside
`resolvedGamepad` for the same reason it exists in the Rust client — the echo is
only this pad's answer when the pad declared what we asked for.

Where Apple differs from the SDL client, and better: it never powers the IMU. The
existing code already declined to activate sensors when forwarding was off,
reasoning that with nothing to forward there is no reason to make the pad stream
gyro over Bluetooth and burn its battery — `closeSlot` is careful to power them
back down for exactly that reason. A host that built this pad a backend without a
motion plane is the same situation, so it takes the same branch. No per-sample
check, no handler attached, and a DualSense in an X-Box-class session stops paying
for a sensor nobody reads.

The hint fires only for a pad that really has a gyro (`motion.hasRotationRate`).
A gravity-only GCMotion — what an X-Box controller exposes — would otherwise
produce a notice about a feature the player never had. That is a narrower
condition than the capture path itself uses, deliberately: making the capture
gate agree is G13's job and its own change.

The badge sits in the bottom-centre stack with the muted-mic badge and the
start-of-stream banner, at every stats tier and with the overlay off, because
this is not a statistic. Unlike the mic badge it is not a control: the setting is
not reachable mid-stream on every platform and applies from the next session
anyway. So it states the fact, names the setting, and leaves after the banner's
same 6 s. Every platform including tvOS — a DualSense on an Apple TV is an
ordinary way to play, and is exactly the pad this happens to. The model owns the
expiry rather than the view, so a second pad's hint replaces the first cleanly
instead of stacking, and ending the session cancels a pending clear rather than
carrying a stale hint into the next stream.

Non-vacuity proven by mutation, not assumed: collapsing the predicate to
`resolved.hasMotion` fails 4 assertions, including the mixed-pad row that is the
whole reason it is not a session-level check. The table mirrors the Rust one row
for row — a client that disagrees with the host here either kills a working gyro
or streams ~250 Hz into a void, and both are silent.

Gate: macOS `swift build` + the FULL suite (210 tests, 5 skipped, 0 failures) with
the two new cases observed in the run's own output, and the iOS-triple typecheck
green (`arm64-apple-ios17.0`, iOS slices + hand-assembled xcframework per the
memory recipe) — the badge and the overlay it joins are on every platform, so the
macOS build alone would not have covered them. tvOS remains unverifiable from
this Mac; the badge deliberately reuses the neighbouring banner's shape rather
than introducing anything tvOS-specific.
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.
G16 step 1, and the second half of what 9e9bb9f4 started. That commit fixed the SIGN
of acceleration (Apple reports the gravity vector, pointing down; a pad reports proper
acceleration, pointing up). This fixes the FRAME, which is a separate defect and was
never going to show up as an inverted axis — it shows up as roll where the game reads
yaw.

The wire is a unit passthrough. `dualsense_proto::write_report` puts gyro[0..3] and
accel[0..3] straight into the virtual pad's report bytes 16.. and 22.., in order, with
no permutation — the same slots a real DualSense fills. So the frame the wire is
DEFINED in is the pad's own report frame, and forwarding GameController's x/y/z
unconverted was speaking a different language with the same vocabulary.

Both frames measured 2026-08-07 from ONE physical DualSense on one desk, read twice —
over raw HID and through GameController — so this is two readings of the same
controller in the same orientations rather than two documents:

  DualSense report frame: (Right, Up, Backward)   axis 0 pitch, 1 yaw, 2 roll
  GameController frame:   (Right, Forward, Up)

Right is already slot 0; Up is GC's z and moves to slot 1; slot 2 wants Backward, which
is GC's y negated. Hence (x, z, -y), applied to gyro AND acceleration because it is a
change of basis and both live in that basis.

Notable: the wire's documented naming was right all along — gyro[0]=pitch, [1]=yaw,
[2]=roll is exactly what the hardware does. And Android needs no remap at all: it
forwards the pad's own axis order un-remapped, which is correct. Its old reading was
purely the scale bug f6de620f fixed. Only Apple was converting nothing.

How the hardware frame was established, since a wrong frame here is invisible. Gravity
at rest put +0.997 g on axis 1. Yaw clockwise-from-above drove axis 1 negative (98% of
the rotation), pitch nose-down drove axis 0 negative (100%), roll right-side-down drove
axis 2 negative (95%) — plain right-hand rule, and (a0 x a1 = a2) confirms the triad is
right-handed. The accelerometer then corroborated the gyro's assignment independently:
under pitch-down axis 2 rose 0.160 -> +0.339 (nose down raises the back, so world-up
gains a Backward component) and under roll-right-down axis 0 went +0.021 -> -0.197,
while yaw left acceleration untouched. Two different physical quantities agreeing on
one triad.

Apple's frame took four attempts, and the failures are worth recording because each was
a different way to be confidently wrong:
  - peak |w| over a window containing BOTH the tip-down and the return stroke can record
    the return, with the opposite sign. Yaw (a continuous one-way spin) was unaffected;
    pitch and roll were exactly the two that disagreed with everything else.
  - reading `gravity + userAcceleration` when `hasGravityAndUserAcceleration` is FALSE
    yields a constant (0,0,1) in every orientation. It looks like data. The tell is that
    it never moves. The client's own else-branch on `m.acceleration` is the correct read
    and is what the instrument now mirrors.
  - `da/dt = -w x a` holds only for gravity, so testing it during vigorous waving — when
    `m.acceleration` carries inseparable linear acceleration — fits nothing.
The frame that survived all of that: static poses, three of them, three repetitions
each. Nose-down moved axis 1 by -0.635 (so axis 1 is Forward), right-side-down moved
axis 0 by -0.686 (so axis 0 is Right), flat put +0.99 on axis 2 (Up). That conclusion
holds whether or not the acceleration negation is right, because negating flips the
measured vector and the physical direction it represents together.

Confidence, stated honestly. The accelerometer half is solid: nine pose measurements,
and mapping the flat pose through gives (+0.005, +0.992, +0.192) against the hardware's
own (+0.021, +0.997, +0.160) — all three components, including the small tilt term that
is what distinguishes this mapping from the five other permutations that also put
gravity on slot 1. That the gyro shares the frame unmodified rests on a weaker
measurement: a gravity-dominated consistency test that preferred (+x,+y,+z) by 1.22x,
which is a margin, not a landslide. It is corroborated by the yaw reading (the one
rotation measured without the return-stroke ambiguity) agreeing with right-hand rule in
that frame, and by the peak-vs-return mechanism explaining the two that did not. A
device-side confirmation is still owed and is listed below.

The tests carry the measurements, not just the conclusion. Resting gravity is asserted
against BOTH readings of that pose; each rotation is asserted to reach the slot the wire
reads it from; and two properties guard the shape rather than the numbers — that the
conversion is an isometry (a basis change may not stretch anything) and that it
preserves handedness. That last one matters most: a permutation with the wrong number of
sign flips is a REFLECTION, which looks plausible axis by axis and inverts every
rotation. Mutation-checked: dropping only the negation fails 6 assertions across 4 of
the 5 cases, the handedness test among them.

Owed, and not claimed done: on-glass re-verification through a real iOS device, together
with the two already owed on that rig (the 9e9bb9f4 sign fix and the Android
calibration read) — one pass covers all three. G14's DualSense neutral acceleration is
now unblocked by this measurement (1 g on slot 1, not the z-up the notes assumed) but is
deliberately left to its own change; and that constant must NOT be propagated to
switch_proto, which is a different device whose frame nobody has measured.

Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) with the
five new cases observed in the run's own output, and the iOS-triple typecheck green.
G14, unblocked by the frame measurement in efb7f991 — the plan deliberately left this
one alone until the up axis was known, on the grounds that a confidently wrong constant
would be worse than an obviously wrong zero. It is known now.

A virtual DualSense, DualShock 4 or Steam Deck that had received no motion reported
acceleration `[0, 0, 0]`. That is not "no data": zero proper acceleration means free
fall, which is a definite claim about the physical world and one that is never true of
a controller sitting on a desk or held in someone's hands — both read 1 g up. Anything
that interprets the accelerometer gets a confident wrong answer rather than a boring
right one.

It is worst exactly where it is least visible. A pad with no gyro at all — an X-Box
controller forwarded as a DualSense, which is what "Automatic" does for anything not
Sony or Valve — never sends motion, so it sits on that neutral for the entire session,
telling every game that reads it that the controller is falling. `switch_proto` has
always done this correctly on its own up axis, which is what made the gap visible in the
first place.

Which axis, and why it took a measurement. The wire is a unit passthrough into the
virtual pad's report, so the wire's up axis is the pad's own, and on 2026-08-07 a real
DualSense read over raw HID put `+0.997 g` on report axis 1 at rest, in a frame pinned
the same session as (Right, Up, Backward). So `MOTION_NEUTRAL_ACCEL` is `[0, 10000, 0]`
— NOT the z-up the notes had assumed from `switch_proto`'s documentation, which is why
guessing would have shipped a backend confidently disagreeing with the hardware.

The constant lives in punktfunk-core beside the units it is expressed in, and every
backend derives from it rather than restating it. The Deck's neutral in particular goes
through `steam_remap::motion_wire_to_deck`, the same rescale a real sample takes, so the
neutral and the live path can never end up with two opinions about what 1 g is — its
`hid-steam` resolution stays in exactly one place. The DS4 needs no separate change: it
reuses `DsState`.

`switch_proto` is deliberately NOT touched, and the test says so. It is a different
device on a different driver, its up axis is its own, and nobody has measured its frame
— aligning it to the DualSense for consistency would be the same unmeasured guess this
commit exists to avoid, just in the other direction.

Non-vacuity proven both ways rather than assumed. Moving the up axis to slot 2 (the old
z-up assumption) fails on the wire constant itself, which is what makes the measurement
load-bearing rather than decorative; reverting both neutrals to `[0, 0, 0]` fails on the
DualSense assertion with the message naming the defect. Each backend is checked in ITS
OWN units, because hard-coding "1 g" three times is how the halves of a unit contract
drift apart.

Gate (Linux CI image): fmt, build, `clippy --locked --all-targets -D warnings` across
punktfunk-core / pf-inject / pf-client-core, and both test suites — green, with
`Running tests/motion_contract.rs` and the new case's own `... ok` line observed in the
log rather than inferred from a green exit (`cargo test` stops after the first failing
binary, so a green-looking run can mean the contract test never executed at all).
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
0170da2a5f
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.
enricobuehler added 2 commits 2026-08-07 17:35:05 +00:00
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.
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
1eab4b6626
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)`.
enricobuehler added 4 commits 2026-08-07 18:18:53 +00:00
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.
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.
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
7a4cdac5b7
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.
enricobuehler added 1 commit 2026-08-07 19:28:25 +00:00
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
ee61e8c9ba
Reverts half of 1eab4b66 and closes G10's open frame question, both settled by the same
measurement.

1eab4b66 made two corrections to the Apple phone-gyro mirror. The negation was right and
stays: Apple reports the gravity VECTOR, pointing down, while an accelerometer measures
proper acceleration, pointing up at rest, and the wire carries the latter. The frame
change was wrong, and this removes it.

The mistake was a name collision. Two different frames are both called "the controller
frame". GCMotion reports a CONTROLLER in (Right, Forward, Up) — measured on a real
DualSense — which is not the wire's frame, which is why `GamepadCapture.forwardMotion`
converts. The mirror's orientation remap resolves THIS DEVICE into the frame its header
describes, x right, y up, z out of the screen. For the pose that mirror exists to serve —
a phone clipped upright with the screen facing the player — "out of the screen" points at
the player, so that frame is (Right, Up, Backward), which IS the wire's. It was already
correct. Applying the controller path's conversion on top rotated it out of true: a phone
sitting still would have reported gravity as −1 g on the roll axis rather than +1 g up,
i.e. claimed to be lying on its edge.

Reasoning by analogy is what produced it — "the mirror says controller frame, the capture
path says controller frame, so the same fix applies". Both files say it; they mean
different things.

What caught it was measuring the Android twin, which does the same thing straight through.
On glass: a DualSense on Bluetooth to a phone, streaming to a Linux host, reads +1 g on the
up axis end to end. Had the Apple mirror needed a conversion, the Android one would have
needed the same one and would have been visibly wrong. It is not.

The same run settles G10's frame, which shipped straight-through and explicitly unverified
because nobody had put a Bluetooth pad in front of the platform sensor framework. Now
somebody has. `PadSensors`' own first-sample log read `accel 0, 10000, 0` — exactly 1 g on
slot 1 — and at the far end hid-playstation published gravity as +0.991 g on ABS_Y, with
every rotation driving its correctly-named axis and the signs agreeing with gravity's
independent witness on 95 of 100 rotating samples. Android hands a controller's sensors
over in the pad's own frame, as documented. No remap, and the comment now says measured
instead of assumed.

Worth recording why the earlier suspicion was wrong, since it is the same trap in the other
direction: Android's DEVICE sensor frame really does put +z out of the screen, so a flat
phone puts gravity on z — but a CONTROLLER's sensors are reported in the controller's
frame, not the phone's. One platform, two conventions, chosen by what the sensor is
attached to.

Gate: Apple macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the
iOS-triple typecheck, which is what actually compiles `DeviceGyro.swift`; Android
`:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`. Green.

Still owed: `DeviceGyroRemap`'s four orientation matrices remain derived — this run used a
controller's own sensors, not the mirror, so it says nothing about them. They need a
gyro-less pad on wire index 0 and a phone turned through all four orientations.
All checks were successful
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
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin worktree-gyro-p0-correctness:worktree-gyro-p0-correctness
git checkout worktree-gyro-p0-correctness
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#99